Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ name: deploy to github pages
on:
push:
branches: [main]
# for the days github's push events don't make it to actions
workflow_dispatch:

permissions:
contents: read
Expand Down
15 changes: 15 additions & 0 deletions src/lib/fs/virtual-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ export async function getVirtualRoot(): Promise<FileSystemDirectoryHandle> {
return root;
}

// the projects that live inside the browser, newest first is not known,
// so alphabetical
export async function listVirtualProjects(): Promise<string[]> {
try {
const root = await getVirtualRoot();
const names: string[] = [];
for await (const entry of root.values()) {
if (entry.kind === 'directory') names.push(entry.name);
}
return names.sort((a, b) => a.localeCompare(b));
} catch {
return [];
}
}

// whole projects can live there when the real file system is out of reach
// (firefox, safari). writing needs createWritable on file handles, which not
// every browser exposes on the main thread
Expand Down
87 changes: 69 additions & 18 deletions src/lib/project/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export function supportsFileSystemAccess(): boolean {
// browsers without the file system access api keep their projects in the
// origin private file system. the handles behave like the real ones, the
// files just live inside the browser instead of a folder on disk
const VIRTUAL_PROJECT_KEY = 'texbrain-virtual-project';

async function pickProjectRoot(): Promise<FileSystemDirectoryHandle> {
if (supportsFileSystemAccess()) {
return window.showDirectoryPicker({ mode: 'readwrite' });
Expand Down Expand Up @@ -65,7 +63,6 @@ export async function handleNewProject() {
projectHandle.set(projectDir);
entryPoint.set('main.tex');
await handleOpenFileFromTree(mainFile, 'main.tex');
rememberVirtualProject(name);
addToast(`Created project: ${name}`, 'success', 2000);
} catch (e: any) {
if (e.name !== 'AbortError') {
Expand All @@ -89,7 +86,6 @@ export async function cloneProject(url: string, name: string): Promise<void> {
projectHandle.set(projectDir);

await pickEntryPoint(tree);
rememberVirtualProject(name);

addToast(`Cloned: ${name}`, 'success', 2000);
}
Expand All @@ -108,18 +104,8 @@ async function pickEntryPoint(tree: TreeEntry[]) {
}
}

function rememberVirtualProject(name: string) {
if (!supportsFileSystemAccess()) localStorage.setItem(VIRTUAL_PROJECT_KEY, name);
}

// without folder access the only place a project can be is the origin
// private file system, so open folder brings the last one back
async function openVirtualProject() {
const name = localStorage.getItem(VIRTUAL_PROJECT_KEY);
if (!name || !supportsVirtualProjects()) {
addToast('Opening folders needs Chrome or Edge. New Project creates a project inside the browser instead.', 'info', 6000);
return;
}
// a project that lives inside the browser, by name
export async function openBrowserProject(name: string) {
try {
const root = await getVirtualRoot();
const projectDir = await root.getDirectoryHandle(name);
Expand All @@ -130,11 +116,76 @@ async function openVirtualProject() {
addToast(`Opened project: ${name}`, 'success', 2000);
await pickEntryPoint(tree);
} catch (e: any) {
if (e.name === 'NotFoundError') localStorage.removeItem(VIRTUAL_PROJECT_KEY);
addToast(`Failed to open project: ${e.message}`, 'error');
}
}

// browsers without a directory handle api still have the folder upload
// picker. it hands out plain files, so the folder is copied into a project
// inside the browser and edits stay there until they are downloaded or pushed
function pickFolderFiles(): Promise<{ name: string; files: File[] } | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
(input as HTMLInputElement & { webkitdirectory: boolean }).webkitdirectory = true;
input.onchange = () => {
const files = Array.from(input.files || []);
if (files.length === 0) { resolve(null); return; }
const first = (files[0] as File & { webkitRelativePath: string }).webkitRelativePath;
resolve({ name: first.split('/')[0] || 'project', files });
};
input.oncancel = () => resolve(null);
input.click();
});
}

const SKIPPED_DIRS = ['node_modules', '.git', '__pycache__'];
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;

async function importFolderIntoBrowser(name: string, files: File[]): Promise<FileSystemDirectoryHandle> {
const root = await getVirtualRoot();
const projectDir = await root.getDirectoryHandle(name, { create: true });
for (const file of files) {
const rel = (file as File & { webkitRelativePath: string }).webkitRelativePath.split('/').slice(1);
const fileName = rel.pop();
if (!fileName || rel.some(part => SKIPPED_DIRS.includes(part)) || file.size > MAX_IMPORT_BYTES) continue;
let dir = projectDir;
for (const part of rel) dir = await dir.getDirectoryHandle(part, { create: true });
const handle = await dir.getFileHandle(fileName, { create: true });
const writable = await handle.createWritable();
await writable.write(await file.arrayBuffer());
await writable.close();
}
return projectDir;
}

async function openFolderWithoutHandles() {
if (!supportsVirtualProjects()) {
// nowhere to copy to, the tex files at least can be opened as tabs
const picked = await pickFolderFiles();
if (!picked) return;
for (const file of picked.files) {
if (/\.(tex|bib|sty|cls)$/i.test(file.name)) openFileTab(file.name, await file.text(), null);
}
addToast('Your browser can\'t store projects, the files were opened as tabs instead', 'warning', 6000);
return;
}
const picked = await pickFolderFiles();
if (!picked) return;
addToast(`Copying ${picked.files.length} files into the browser...`, 'info', 3000);
try {
const projectDir = await importFolderIntoBrowser(picked.name, picked.files);
const tree = await readTreeFromHandle(projectDir);
projectTree.set(tree);
projectName.set(picked.name);
projectHandle.set(projectDir);
addToast(`Opened ${picked.name}. It is a copy inside the browser, download or push to get changes out.`, 'success', 6000);
await pickEntryPoint(tree);
} catch (e: any) {
addToast(`Failed to open folder: ${e.message}`, 'error');
}
}

export async function handleOpenFile() {
try {
if (supportsFileSystemAccess()) {
Expand All @@ -157,7 +208,7 @@ export async function handleOpenFile() {

export async function handleOpenDirectory() {
if (!supportsFileSystemAccess()) {
await openVirtualProject();
await openFolderWithoutHandles();
return;
}
try {
Expand Down
21 changes: 20 additions & 1 deletion src/lib/ui/FileTree.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
<script lang="ts">
import { files, activeFileId, setActiveTab, projectTree, projectName, projectHandle, entryPoint } from '$lib/project/store';
import { handleOpenFileFromTree, handleOpenDirectory, handleNewProject, handleNewFileInProject, handleDeleteFileInProject, handleRenameFileInProject, refreshProjectTree, moveFileInProject, selectEntryPoint } from '$lib/project/manager';
import { handleOpenFileFromTree, handleOpenDirectory, handleNewProject, handleNewFileInProject, handleDeleteFileInProject, handleRenameFileInProject, refreshProjectTree, moveFileInProject, selectEntryPoint, openBrowserProject, supportsFileSystemAccess } from '$lib/project/manager';
import { listVirtualProjects } from '$lib/fs/virtual-fs';
import { cloneDialogOpen } from '$lib/stores/app';
import { browser } from '$app/environment';
import type { TreeEntry } from '$lib/project/types';

// projects that live inside the browser, only a thing where folders on
// disk are out of reach
const inBrowser = browser && !supportsFileSystemAccess();
let browserProjects: string[] = [];
$: if (inBrowser && $projectHandle !== undefined) listVirtualProjects().then(names => { browserProjects = names; });
import { gitFileStatuses, gitEnabled } from '$lib/git/store';

function gitStatusLetter(path: string): string | null {
Expand Down Expand Up @@ -319,6 +327,17 @@
</svg>
Clone Repository
</button>
{#if browserProjects.length > 0}
<span class="empty-text">In this browser</span>
{#each browserProjects as name (name)}
<button class="empty-btn" on:click={() => openBrowserProject(name)} title="A project stored inside the browser">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M2 13V3a1 1 0 011-1h4l2 2h4a1 1 0 011 1v8a1 1 0 01-1 1H3a1 1 0 01-1-1z" stroke="currentColor" stroke-width="1.2"/>
</svg>
{name}
</button>
{/each}
{/if}
</div>
{#if $files.length > 0}
<div class="section-label">FILES</div>
Expand Down
16 changes: 15 additions & 1 deletion src/routes/editor/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import { sidebarOpen, previewOpen, snippetPickerOpen, commandPaletteOpen, cloneDialogOpen, compileStatus, compileLog, compileRawLog, compileProblems, previewTab, addToast } from '$lib/stores/app';
import { files, activeFile, activeFileId, updateFileContent, markFileSaved, projectHandle, entryPoint, openFileTab, closeFileTab, setActiveTab } from '$lib/project/store';
import { readFileFromHandle } from '$lib/fs/local-fs';
import { handleOpenFile, handleSaveFile, handleSaveFileAs, handleDroppedFiles, handleOpenDirectory, handleNewProject, cloneProject, reopenEntryPointPicker, refreshProjectTree, supportsFileSystemAccess, findProjectFile, handleOpenFileFromTree } from '$lib/project/manager';
import { handleOpenFile, handleSaveFile, handleSaveFileAs, handleDroppedFiles, handleOpenDirectory, handleNewProject, cloneProject, reopenEntryPointPicker, refreshProjectTree, supportsFileSystemAccess, findProjectFile, handleOpenFileFromTree, openBrowserProject } from '$lib/project/manager';
import { listVirtualProjects } from '$lib/fs/virtual-fs';
import { insertAtCursor, createEditor, replaceEditorContent, gotoLine } from '$lib/editor/setup';
import { tick } from 'svelte';
import { locateByNeedle, type Problem } from '$lib/compiler/log';
Expand Down Expand Up @@ -122,6 +123,11 @@
let cloning = false;
const hasFolderAccess = browser && supportsFileSystemAccess();

// projects stored inside the browser, for the browsers that can't open a
// folder on disk. refreshed whenever a project comes or goes
let browserProjects: string[] = [];
$: if (browser && !hasFolderAccess && $projectHandle !== undefined) listVirtualProjects().then(names => { browserProjects = names; });

$: isMobile = windowWidth < 900;

function buildEditor() {
Expand Down Expand Up @@ -1012,6 +1018,14 @@
Clone Repository
</button>
</div>
{#if browserProjects.length > 0}
<p class="welcome-desc">Projects in this browser</p>
<div class="welcome-actions">
{#each browserProjects as name (name)}
<button class="welcome-btn secondary" on:click={() => openBrowserProject(name)}>{name}</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
Expand Down
Loading