diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 078e41e..95aa293 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "core:window:allow-start-dragging", "core:window:allow-close", "opener:default", + "opener:allow-reveal-item-in-dir", "dialog:default", "dialog:allow-open", "dialog:allow-save" diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 08b51d0..35971b7 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -16,6 +16,7 @@ pub struct DocumentLink { pub base_hash: String, pub role: String, pub base_content: String, + pub synced_at: Option, } const SCHEMA: [&str; 5] = [ @@ -41,7 +42,8 @@ const SCHEMA: [&str; 5] = [ document_id TEXT NOT NULL, base_hash TEXT NOT NULL, role TEXT NOT NULL, - base_content TEXT + base_content TEXT, + synced_at TEXT )", "CREATE TABLE IF NOT EXISTS thumbnails ( path TEXT PRIMARY KEY, @@ -51,6 +53,9 @@ const SCHEMA: [&str; 5] = [ )", ]; +const MIGRATIONS: [&str; 1] = + ["ALTER TABLE document_links ADD COLUMN synced_at TEXT"]; + impl Store { pub fn open(app: &AppHandle) -> Result { let dir = app @@ -73,6 +78,10 @@ impl Store { connection.execute(statement, []).map_err(|e| e.to_string())?; } + for statement in MIGRATIONS { + let _ = connection.execute(statement, []); + } + Ok(Store { connection: Mutex::new(connection), }) @@ -283,14 +292,23 @@ impl Store { ) -> Result<(), String> { self.with(|connection| { connection.execute( - "INSERT INTO document_links (path, document_id, base_hash, role, base_content) - VALUES (?1, ?2, ?3, ?4, ?5) + "INSERT INTO document_links + (path, document_id, base_hash, role, base_content, synced_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(path) DO UPDATE SET document_id = excluded.document_id, base_hash = excluded.base_hash, role = excluded.role, - base_content = excluded.base_content", - params![path, document_id, base_hash, role, base_content], + base_content = excluded.base_content, + synced_at = excluded.synced_at", + params![ + path, + document_id, + base_hash, + role, + base_content, + chrono::Utc::now().to_rfc3339() + ], )?; Ok(()) }) @@ -300,7 +318,7 @@ impl Store { self.with(|connection| { connection .query_row( - "SELECT document_id, base_hash, role, base_content + "SELECT document_id, base_hash, role, base_content, synced_at FROM document_links WHERE path = ?1", params![path], |row| { @@ -309,6 +327,7 @@ impl Store { base_hash: row.get(1)?, role: row.get(2)?, base_content: row.get::<_, Option>(3)?.unwrap_or_default(), + synced_at: row.get(4)?, }) }, ) @@ -316,6 +335,38 @@ impl Store { }) } + pub fn all_space_links(&self) -> Result)>, String> { + self.with(|connection| { + let mut statement = connection.prepare( + "SELECT path, space_id, last_synced_at FROM projects + WHERE space_id IS NOT NULL", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + rows.collect::>>() + }) + } + + pub fn all_document_links(&self) -> Result)>, String> { + self.with(|connection| { + let mut statement = connection + .prepare("SELECT path, document_id, synced_at FROM document_links")?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + rows.collect::>>() + }) + } + pub fn forget_document_link(&self, path: &str) -> Result<(), String> { self.with(|connection| { connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?; diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 055e1b2..b7634e9 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -848,3 +848,52 @@ pub fn push_document( Err(other) => Err(describe(other)), } } + +#[derive(Deserialize, Serialize, Clone)] +pub struct CloudFile { + pub id: String, + pub name: String, + pub mime_type: String, + pub folder_id: Option, + pub created_at: String, +} + +#[derive(Deserialize)] +pub struct CloudFileContent { + pub name: String, + pub content: String, +} + +pub fn list_account_files( + server_url: &str, + token: &str, + folder_id: Option<&str>, +) -> Result, String> { + let mut request = agent() + .get(&endpoint(server_url, "/files")) + .set("Authorization", &format!("Bearer {}", token)); + + if let Some(folder) = folder_id { + request = request.query("folder_id", folder); + } + + request + .call() + .map_err(describe)? + .into_json::>() + .map_err(|e| e.to_string()) +} + +pub fn pull_account_file( + server_url: &str, + token: &str, + file_id: &str, +) -> Result { + agent() + .get(&endpoint(server_url, &format!("/files/{}", file_id))) + .set("Authorization", &format!("Bearer {}", token)) + .call() + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/thumbnails.rs b/src-tauri/src/thumbnails.rs index b8af184..0c13c4a 100644 --- a/src-tauri/src/thumbnails.rs +++ b/src-tauri/src/thumbnails.rs @@ -27,6 +27,26 @@ fn modified_seconds(path: &Path) -> i64 { .unwrap_or(0) } +fn newest_change_seconds(dir: &Path) -> i64 { + let mut newest = 0; + for entry in walkdir::WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let seconds = entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(0); + if seconds > newest { + newest = seconds; + } + } + newest +} + fn mime_for(name: &str) -> &'static str { let lower = name.to_lowercase(); if lower.ends_with(".png") { @@ -115,12 +135,17 @@ pub fn thumbnail(app: &AppHandle, store: &Store, path: &str) -> Result void; onviewimage: (paths: string[], index: number) => void; ondownloaddocument: (documentId: string, title: string) => void; + onremovedownload: (path: string) => void; + ondownloadfile: (fileId: string, name: string) => void; onclonespace: (spaceId: string, name: string) => void; ondeletespace: (spaceId: string) => void; onnewspace: () => void; @@ -37,6 +41,8 @@ onlink, onviewimage, ondownloaddocument, + onremovedownload, + ondownloadfile, onclonespace, ondeletespace, onnewspace, @@ -44,6 +50,7 @@ }: Props = $props(); let menuFor = $state(null); + let menuAt = $state({ x: 0, y: 0 }); const trail = $derived(breadcrumbs()); @@ -66,6 +73,34 @@ ); let thumbs = $state>({}); + let cloudThumbs = $state>({}); + + $effect(() => { + if (app.scope !== "cloud") return; + + const pending = [ + ...app.linkedDocuments.map((linked) => linked.path), + ...app.linkedSpaces.map((linked) => linked.path), + ]; + let cancelled = false; + + (async () => { + for (const path of pending) { + if (cancelled) return; + if (cloudThumbs[path]) continue; + try { + const result = await api.thumbnail(path); + if (!cancelled) cloudThumbs[path] = result; + } catch { + continue; + } + } + })(); + + return () => { + cancelled = true; + }; + }); $effect(() => { const pending = documents.map((entry) => entry.path); @@ -89,14 +124,6 @@ }; }); - const localSpaceIds = $derived( - new Set( - app.entries - .filter((entry) => entry.space_id) - .map((entry) => entry.space_id as string), - ), - ); - const iconFor: Record = { project: "ph:folder-star", folder: "ph:folder", @@ -111,6 +138,27 @@ file: "text-[var(--color-ink-muted)]", }; + function relativeTime(value: string | null): string { + if (!value) return ""; + const then = new Date(value).getTime(); + if (Number.isNaN(then)) return ""; + + const minutes = Math.round((Date.now() - then) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + if (minutes < 1440) return `${Math.round(minutes / 60)}h ago`; + return `${Math.round(minutes / 1440)}d ago`; + } + + function syncLabel(entry: BrowseEntry): string { + if (entry.sync_state === "pending") return "Local changes not yet synced"; + if (entry.sync_state === "synced") { + const when = relativeTime(entry.last_synced_at); + return when ? `Synced ${when}` : "Synced"; + } + return "Linked to the cloud"; + } + const imagePaths = $derived( app.entries .filter((entry) => api.isImagePath(entry.path)) @@ -140,10 +188,132 @@ } -{#snippet actions(entry: BrowseEntry, offset: string)} +{#snippet cloudCard( + icon: string, + title: string, + meta: string, + link: { sync_state: string | null; path: string } | undefined, + onopen: (() => void) | null, + ondownload: () => void, + onremove: (() => void) | null, +)} +
+ {#if link && cloudThumbs[link.path]} +
+ {#if cloudThumbs[link.path].kind === "svg"} + + {@html cloudThumbs[link.path].data} + + {:else} + {title} + {/if} +
+ {/if} + +
+
+ +
+

{title}

+

{meta}

+
+ + {#if link} + + + {link.sync_state === "pending" ? "Unsynced" : "Synced"} + + {/if} +
+ +
+ {#if link && onopen} + + {:else} + + {/if} + + {#if onremove} + + {/if} +
+
+
+{/snippet} + +{#snippet syncBadge(entry: BrowseEntry)} + {#if entry.cloud_linked} + + {#if entry.sync_state === "pending"} + + {:else} + + {/if} + + {/if} +{/snippet} + +{#snippet actions(entry: BrowseEntry)} - {@render actions(entry, "top-2")} + {@render actions(entry)} {/each} @@ -413,23 +587,38 @@ {/if} - - - {entry.name} - - - {formatSize(entry.size)} - {#if entry.modified} - · {formatDate(entry.modified)} - {/if} - - - {@render actions(entry, "top-2")} +
+ + + {@render actions(entry)} +
{/each} @@ -456,30 +645,33 @@ {:else} -
+
+ {#if app.cloudLoading} {/if}
@@ -512,33 +704,66 @@
- {#each app.cloudDocuments as document (document.id)} + {#each app.cloudDocuments as entry (entry.id)} + {@const linked = linkedDocument(entry.id)} + {@render cloudCard( + "ph:file-text", + entry.title, + linked + ? `Document · ${entry.role}` + : `${entry.role} · ${formatDate(entry.updated_at)}`, + linked, + linked ? () => openTarget(linked.path) : null, + () => ondownloaddocument(entry.id, entry.title), + linked ? () => onremovedownload(linked.path) : null, + )} + {/each} +
+ {/if} + + {#if app.cloudFiles.length > 0} +

+ Images and fonts +

+
+ {#each app.cloudFiles as file (file.id)}
- - {document.title} - - - {document.role} · {formatDate(document.updated_at)} - +
+

+ {file.name} +

+

+ {formatDate(file.created_at)} +

+
{/each}
{/if} - {#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0} + {#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0 && app.cloudFiles.length === 0}
@@ -556,47 +781,18 @@ {/if}
{#each app.spaces as space (space.id)} -
-
- - {#if localSpaceIds.has(space.id)} - - - - {/if} -
- - {space.name} - - {space.role} · {formatDate(space.updated_at)} - - -
- {#if !localSpaceIds.has(space.id)} - - {/if} - {#if space.role === "owner"} - - {/if} -
-
+ {@const linked = linkedSpace(space.id)} + {@render cloudCard( + "ph:folder-star", + space.name, + linked + ? `Project · ${space.role}` + : `${space.role} · ${formatDate(space.updated_at)}`, + linked, + linked ? () => openTarget(linked.path) : null, + () => onclonespace(space.id, space.name), + space.role === "owner" ? () => ondeletespace(space.id) : null, + )} {/each}
{/if} diff --git a/src/lib/ts/api.ts b/src/lib/ts/api.ts index dc02f16..b9e512f 100644 --- a/src/lib/ts/api.ts +++ b/src/lib/ts/api.ts @@ -13,6 +13,7 @@ export interface Settings { export interface FileEntry { path: string; name: string; + is_dir: boolean; is_text: boolean; size: number; } @@ -96,6 +97,8 @@ export interface BrowseEntry { space_id: string | null; last_synced_at: string | null; child_count: number; + cloud_linked: boolean; + sync_state: "synced" | "pending" | null; } export interface TargetInfo { @@ -125,6 +128,15 @@ export const renameEntry = (path: string, newName: string) => export const deleteEntry = (path: string) => invoke("delete_entry", { path }); +export const moveEntry = (path: string, destination: string) => + invoke("move_entry", { path, destination }); + +export const duplicateEntry = (path: string) => + invoke("duplicate_entry", { path }); + +export const absolutePath = (path: string) => + invoke("absolute_path", { path }); + export const uploadEntry = ( parent: string, name: string, @@ -288,6 +300,20 @@ export const cloudListDocuments = (folderId?: string | null) => export const cloudListShared = () => invoke("cloud_list_shared"); +export interface CloudFile { + id: string; + name: string; + mime_type: string; + folder_id: string | null; + created_at: string; +} + +export const cloudListFiles = (folderId?: string | null) => + invoke("cloud_list_files", { folderId: folderId ?? null }); + +export const cloudDownloadFile = (fileId: string) => + invoke("cloud_download_file", { fileId }); + export const cloudDownloadDocument = (documentId: string, parent: string) => invoke("cloud_download_document", { documentId, parent }); @@ -300,6 +326,26 @@ export const cloudResolveDocument = ( serverHash: string, ) => invoke("cloud_resolve_document", { path, content, serverHash }); +export interface LinkedDocument { + path: string; + document_id: string; + synced_at: string | null; + sync_state: "synced" | "pending" | null; +} + +export interface LinkedSpace { + path: string; + space_id: string; + synced_at: string | null; + sync_state: "synced" | "pending" | null; +} + +export const cloudLinkedDocuments = () => + invoke("cloud_linked_documents"); + +export const cloudLinkedSpaces = () => + invoke("cloud_linked_spaces"); + export const cloudDocumentLink = (path: string) => invoke("cloud_document_link", { path }); diff --git a/src/lib/ts/state.svelte.ts b/src/lib/ts/state.svelte.ts index c70f8e4..507c059 100644 --- a/src/lib/ts/state.svelte.ts +++ b/src/lib/ts/state.svelte.ts @@ -3,11 +3,14 @@ import type { Account, BrowseEntry, CloudDocument, + CloudFile, CloudFolder, CompileResult, Conflict, Diagnostic, DocumentLink, + LinkedDocument, + LinkedSpace, Settings, SpaceSummary, TargetInfo, @@ -29,7 +32,10 @@ interface AppState { cloudFolder: string | null | "shared"; cloudFolders: CloudFolder[]; cloudDocuments: CloudDocument[]; + cloudFiles: CloudFile[]; cloudLoading: boolean; + linkedDocuments: LinkedDocument[]; + linkedSpaces: LinkedSpace[]; documentLink: DocumentLink | null; target: TargetInfo | null; @@ -60,7 +66,10 @@ export const app = $state({ cloudFolder: null, cloudFolders: [], cloudDocuments: [], + cloudFiles: [], cloudLoading: false, + linkedDocuments: [], + linkedSpaces: [], documentLink: null, target: null, @@ -163,22 +172,28 @@ export async function refreshCloud() { app.cloudLoading = true; try { + app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []); + app.linkedSpaces = await api.cloudLinkedSpaces().catch(() => []); + if (app.cloudFolder === "shared") { const shared = await api.cloudListShared(); app.cloudDocuments = shared.documents; app.spaces = shared.spaces; app.cloudFolders = []; + app.cloudFiles = []; } else { - const [folders, documents, spaces] = await Promise.all([ + const [folders, documents, spaces, files] = await Promise.all([ api.cloudListFolders(), api.cloudListDocuments(app.cloudFolder), api.cloudListSpaces(), + api.cloudListFiles(app.cloudFolder), ]); app.cloudFolders = folders.filter( (folder) => (folder.parent_id ?? null) === app.cloudFolder, ); app.cloudDocuments = documents; app.spaces = spaces; + app.cloudFiles = files; } } catch (error) { setError(error); @@ -195,8 +210,7 @@ export async function openCloudFolder(id: string | null | "shared") { export async function downloadDocument(documentId: string, title: string) { try { const path = await api.cloudDownloadDocument(documentId, ""); - app.scope = "local"; - await browseTo(""); + await refreshCloud(); setStatus(`Downloaded '${title}' to this device`); return path; } catch (error) { @@ -205,6 +219,36 @@ export async function downloadDocument(documentId: string, title: string) { } } +export function linkedDocument(documentId: string) { + return app.linkedDocuments.find( + (linked) => linked.document_id === documentId, + ); +} + +export async function downloadCloudFile(fileId: string, name: string) { + try { + await api.cloudDownloadFile(fileId); + setStatus(`'${name}' added to your shared assets`); + } catch (error) { + setError(error); + } +} + +export function linkedSpace(spaceId: string) { + return app.linkedSpaces.find((linked) => linked.space_id === spaceId); +} + +export async function removeDownloadedDocument(path: string) { + try { + await api.deleteEntry(path); + await api.cloudUnlinkDocument(path); + await refreshCloud(); + setStatus("Removed from this device"); + } catch (error) { + setError(error); + } +} + export async function openTarget(path: string) { try { const target = await api.targetInfo(path); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 214faa9..b4d94c5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import Icon from "@iconify/svelte"; import { onMount } from "svelte"; import { save } from "@tauri-apps/plugin-dialog"; + import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import FileViewer from "$lib/components/FileViewer.svelte"; @@ -32,6 +33,7 @@ clearMessages, closeTarget, compile, + downloadCloudFile, downloadDocument, openFile, openTarget, @@ -39,6 +41,7 @@ refreshEntries, refreshSpaces, refreshTarget, + removeDownloadedDocument, runSync, saveAndCompile, scheduleAutosave, @@ -58,8 +61,8 @@ | { kind: "new-space" } | { kind: "delete-space"; id: string } | { kind: "clone-space"; id: string; name: string } - | { kind: "new-file" } - | { kind: "new-subfolder" } + | { kind: "new-file"; parent: string } + | { kind: "new-subfolder"; parent: string } | { kind: "rename-file"; path: string } | { kind: "delete-file"; path: string } | { kind: "login" } @@ -72,6 +75,28 @@ let dialog = $state({ kind: "none" }); let editorView = $state(null); let imageViewer = $state<{ paths: string[]; index: number } | null>(null); + let selectedEntry = $state(null); + let selectedIsDir = $state(false); + let treeDropTarget = $state(null); + + /** Folder that new files, folders, and imports go into. */ + const selectedFolder = $derived( + !selectedEntry + ? "" + : selectedIsDir + ? selectedEntry + : selectedEntry.includes("/") + ? selectedEntry.slice(0, selectedEntry.lastIndexOf("/")) + : "", + ); + + function joinInTarget(parent: string, name: string): string { + return parent ? `${parent}/${name}` : name; + } + + function targetChild(relative: string): string { + return `${app.target?.path}/${relative}`; + } const activeFile = $derived( app.target?.files.find((file) => file.path === app.activePath) ?? null, @@ -151,13 +176,17 @@ setStatus(`Downloaded '${name}' to this device`); }); - async function importFiles() { + async function importFiles(folder?: string) { const sources = await pickFiles("all"); if (sources.length === 0) return; try { if (app.view === "editor" && app.target) { - const imported = await api.importIntoTarget(app.target.path, sources); + const destination = folder ?? selectedFolder; + const imported = await api.importIntoFolder( + destination ? targetChild(destination) : app.target.path, + sources, + ); await refreshTarget(); await compile(); setStatus(`Imported ${imported.length} file(s)`); @@ -171,20 +200,47 @@ } } - const createFileInTarget = (name: string) => + const createFileInTarget = (parent: string, name: string) => guard(async () => { - const path = name.includes(".") ? name : `${name}.typ`; + const file = name.includes(".") ? name : `${name}.typ`; + const path = joinInTarget(parent, file); await api.writeTargetFile(app.target!.path, path, ""); await refreshTarget(); await openFile(path); }); - const createFolderInTarget = (path: string) => + const createFolderInTarget = (parent: string, name: string) => guard(async () => { - await api.createFolderEntry(app.target!.path, path); + await api.createFolderEntry(app.target!.path, joinInTarget(parent, name)); await refreshTarget(); }); + const moveInTarget = (path: string, destination: string) => + guard(async () => { + await api.moveEntry(targetChild(path), targetChild(destination)); + await refreshTarget(); + if (app.activePath === path) { + const name = path.split("/").pop() ?? path; + await openFile(joinInTarget(destination, name)); + } + await compile(); + }); + + const duplicateInTarget = (path: string) => + guard(async () => { + await api.duplicateEntry(targetChild(path)); + await refreshTarget(); + }); + + async function revealInTarget(path: string) { + try { + const absolute = await api.absolutePath(targetChild(path)); + await revealItemInDir(absolute); + } catch (error) { + setError(error); + } + } + const renameFile = (path: string, next: string) => guard(async () => { const payload = await api.readTargetFile(app.target!.path, path); @@ -283,14 +339,27 @@ return null; } - async function dropPaths(paths: string[]) { + /** Folder row under the pointer, so an OS drop lands where it is aimed. */ + function folderUnderPointer(x: number, y: number): string | null { + const element = document + .elementFromPoint(x, y) + ?.closest("[data-tree-path]") as HTMLElement | null; + if (!element) return null; + if (element.dataset.treeDir !== "true") return null; + return element.dataset.treePath ?? ""; + } + + async function dropPaths(paths: string[], folder: string | null) { const destination = dropDestination(); if (!destination || paths.length === 0) return; try { const imported = destination.kind === "target" - ? await api.importIntoTarget(destination.path, paths) + ? await api.importIntoFolder( + folder ? targetChild(folder) : destination.path, + paths, + ) : await api.importIntoFolder(destination.path, paths); if (destination.kind === "target") { @@ -309,11 +378,21 @@ const pending = getCurrentWebview().onDragDropEvent((event) => { if (event.payload.type === "over") { dropActive = dropDestination() !== null; + treeDropTarget = + app.view === "editor" + ? folderUnderPointer( + event.payload.position.x, + event.payload.position.y, + ) + : null; } else if (event.payload.type === "drop") { + const folder = treeDropTarget; dropActive = false; - dropPaths(event.payload.paths); + treeDropTarget = null; + dropPaths(event.payload.paths, folder); } else { dropActive = false; + treeDropTarget = null; } }); @@ -485,6 +564,8 @@ onviewimage={(paths, index) => (imageViewer = { paths, index })} ondownloaddocument={(documentId, title) => downloadDocument(documentId, title)} + onremovedownload={removeDownloadedDocument} + ondownloadfile={downloadCloudFile} onnewspace={() => (dialog = { kind: "new-space" })} onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })} ondeletespace={(id) => (dialog = { kind: "delete-space", id })} @@ -500,43 +581,32 @@ class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5" > - Files + {selectedFolder ? selectedFolder : "Files"} -
- - - -
{ + selectedEntry = path; + selectedIsDir = isDir; + }} onrename={(path) => (dialog = { kind: "rename-file", path })} ondelete={(path) => (dialog = { kind: "delete-file", path })} + onduplicate={duplicateInTarget} + onreveal={revealInTarget} onsetentry={setEntrypoint} + onmove={moveInTarget} + onnewfile={(parent) => (dialog = { kind: "new-file", parent })} + onnewfolder={(parent) => (dialog = { kind: "new-subfolder", parent })} + onimport={(parent) => importFiles(parent)} />
{/if} @@ -718,21 +788,23 @@ onclose={close} /> {:else if dialog.kind === "new-file"} + {@const target = dialog} createFileInTarget(target.parent, name)} onclose={close} /> {:else if dialog.kind === "new-subfolder"} + {@const target = dialog} createFolderInTarget(target.parent, name)} onclose={close} /> {:else if dialog.kind === "rename-file"}