Show cloud files and sync state in the cloud workspace

This commit is contained in:
2026-07-18 18:04:30 -04:00
parent 2383091937
commit 470d8c3d25
8 changed files with 633 additions and 149 deletions
+1
View File
@@ -14,6 +14,7 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-close", "core:window:allow-close",
"opener:default", "opener:default",
"opener:allow-reveal-item-in-dir",
"dialog:default", "dialog:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save" "dialog:allow-save"
+57 -6
View File
@@ -16,6 +16,7 @@ pub struct DocumentLink {
pub base_hash: String, pub base_hash: String,
pub role: String, pub role: String,
pub base_content: String, pub base_content: String,
pub synced_at: Option<String>,
} }
const SCHEMA: [&str; 5] = [ const SCHEMA: [&str; 5] = [
@@ -41,7 +42,8 @@ const SCHEMA: [&str; 5] = [
document_id TEXT NOT NULL, document_id TEXT NOT NULL,
base_hash TEXT NOT NULL, base_hash TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
base_content TEXT base_content TEXT,
synced_at TEXT
)", )",
"CREATE TABLE IF NOT EXISTS thumbnails ( "CREATE TABLE IF NOT EXISTS thumbnails (
path TEXT PRIMARY KEY, 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 { impl Store {
pub fn open(app: &AppHandle) -> Result<Self, String> { pub fn open(app: &AppHandle) -> Result<Self, String> {
let dir = app let dir = app
@@ -73,6 +78,10 @@ impl Store {
connection.execute(statement, []).map_err(|e| e.to_string())?; connection.execute(statement, []).map_err(|e| e.to_string())?;
} }
for statement in MIGRATIONS {
let _ = connection.execute(statement, []);
}
Ok(Store { Ok(Store {
connection: Mutex::new(connection), connection: Mutex::new(connection),
}) })
@@ -283,14 +292,23 @@ impl Store {
) -> Result<(), String> { ) -> Result<(), String> {
self.with(|connection| { self.with(|connection| {
connection.execute( connection.execute(
"INSERT INTO document_links (path, document_id, base_hash, role, base_content) "INSERT INTO document_links
VALUES (?1, ?2, ?3, ?4, ?5) (path, document_id, base_hash, role, base_content, synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(path) DO UPDATE SET ON CONFLICT(path) DO UPDATE SET
document_id = excluded.document_id, document_id = excluded.document_id,
base_hash = excluded.base_hash, base_hash = excluded.base_hash,
role = excluded.role, role = excluded.role,
base_content = excluded.base_content", base_content = excluded.base_content,
params![path, document_id, base_hash, role, base_content], synced_at = excluded.synced_at",
params![
path,
document_id,
base_hash,
role,
base_content,
chrono::Utc::now().to_rfc3339()
],
)?; )?;
Ok(()) Ok(())
}) })
@@ -300,7 +318,7 @@ impl Store {
self.with(|connection| { self.with(|connection| {
connection connection
.query_row( .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", FROM document_links WHERE path = ?1",
params![path], params![path],
|row| { |row| {
@@ -309,6 +327,7 @@ impl Store {
base_hash: row.get(1)?, base_hash: row.get(1)?,
role: row.get(2)?, role: row.get(2)?,
base_content: row.get::<_, Option<String>>(3)?.unwrap_or_default(), base_content: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
synced_at: row.get(4)?,
}) })
}, },
) )
@@ -316,6 +335,38 @@ impl Store {
}) })
} }
pub fn all_space_links(&self) -> Result<Vec<(String, String, Option<String>)>, 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<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
pub fn all_document_links(&self) -> Result<Vec<(String, String, Option<String>)>, 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<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
pub fn forget_document_link(&self, path: &str) -> Result<(), String> { pub fn forget_document_link(&self, path: &str) -> Result<(), String> {
self.with(|connection| { self.with(|connection| {
connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?; connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?;
+49
View File
@@ -848,3 +848,52 @@ pub fn push_document(
Err(other) => Err(describe(other)), 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<String>,
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<Vec<CloudFile>, 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::<Vec<CloudFile>>()
.map_err(|e| e.to_string())
}
pub fn pull_account_file(
server_url: &str,
token: &str,
file_id: &str,
) -> Result<CloudFileContent, String> {
agent()
.get(&endpoint(server_url, &format!("/files/{}", file_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<CloudFileContent>()
.map_err(|e| e.to_string())
}
+27 -2
View File
@@ -27,6 +27,26 @@ fn modified_seconds(path: &Path) -> i64 {
.unwrap_or(0) .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 { fn mime_for(name: &str) -> &'static str {
let lower = name.to_lowercase(); let lower = name.to_lowercase();
if lower.ends_with(".png") { if lower.ends_with(".png") {
@@ -115,12 +135,17 @@ pub fn thumbnail(app: &AppHandle, store: &Store, path: &str) -> Result<Thumbnail
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(); .unwrap_or_default();
let is_project = full.is_dir();
let image = is_image(&name); let image = is_image(&name);
if !image && !name.to_lowercase().ends_with(".typ") { if !image && !is_project && !name.to_lowercase().ends_with(".typ") {
return Err("No preview available".to_string()); return Err("No preview available".to_string());
} }
let modified = modified_seconds(&full); let modified = if is_project {
newest_change_seconds(&full)
} else {
modified_seconds(&full)
};
if let Some((kind, data)) = store.thumbnail(path, modified)? { if let Some((kind, data)) = store.thumbnail(path, modified)? {
return Ok(Thumbnail { kind, data }); return Ok(Thumbnail { kind, data });
+283 -87
View File
@@ -6,6 +6,8 @@
app, app,
breadcrumbs, breadcrumbs,
browseTo, browseTo,
linkedDocument,
linkedSpace,
openCloudFolder, openCloudFolder,
openTarget, openTarget,
refreshCloud, refreshCloud,
@@ -21,6 +23,8 @@
onlink: (entry: BrowseEntry) => void; onlink: (entry: BrowseEntry) => void;
onviewimage: (paths: string[], index: number) => void; onviewimage: (paths: string[], index: number) => void;
ondownloaddocument: (documentId: string, title: string) => void; ondownloaddocument: (documentId: string, title: string) => void;
onremovedownload: (path: string) => void;
ondownloadfile: (fileId: string, name: string) => void;
onclonespace: (spaceId: string, name: string) => void; onclonespace: (spaceId: string, name: string) => void;
ondeletespace: (spaceId: string) => void; ondeletespace: (spaceId: string) => void;
onnewspace: () => void; onnewspace: () => void;
@@ -37,6 +41,8 @@
onlink, onlink,
onviewimage, onviewimage,
ondownloaddocument, ondownloaddocument,
onremovedownload,
ondownloadfile,
onclonespace, onclonespace,
ondeletespace, ondeletespace,
onnewspace, onnewspace,
@@ -44,6 +50,7 @@
}: Props = $props(); }: Props = $props();
let menuFor = $state<string | null>(null); let menuFor = $state<string | null>(null);
let menuAt = $state({ x: 0, y: 0 });
const trail = $derived(breadcrumbs()); const trail = $derived(breadcrumbs());
@@ -66,6 +73,34 @@
); );
let thumbs = $state<Record<string, { kind: string; data: string }>>({}); let thumbs = $state<Record<string, { kind: string; data: string }>>({});
let cloudThumbs = $state<Record<string, { kind: string; data: string }>>({});
$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(() => { $effect(() => {
const pending = documents.map((entry) => entry.path); 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<EntryKind, string> = { const iconFor: Record<EntryKind, string> = {
project: "ph:folder-star", project: "ph:folder-star",
folder: "ph:folder", folder: "ph:folder",
@@ -111,6 +138,27 @@
file: "text-[var(--color-ink-muted)]", 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( const imagePaths = $derived(
app.entries app.entries
.filter((entry) => api.isImagePath(entry.path)) .filter((entry) => api.isImagePath(entry.path))
@@ -140,10 +188,132 @@
} }
</script> </script>
{#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,
)}
<div
class="group flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] transition hover:border-[var(--color-accent)] hover:shadow-sm"
>
{#if link && cloudThumbs[link.path]}
<div
class="flex h-24 items-center justify-center overflow-hidden border-b border-[var(--color-line)] bg-[var(--color-surface-muted)]"
>
{#if cloudThumbs[link.path].kind === "svg"}
<span
class="flex w-full items-start justify-center bg-white p-1 [&_svg]:h-auto [&_svg]:w-full"
>
{@html cloudThumbs[link.path].data}
</span>
{:else}
<img
src={cloudThumbs[link.path].data}
alt={title}
class="h-full w-full object-contain"
/>
{/if}
</div>
{/if}
<div class="flex flex-col gap-2.5 p-3">
<div class="flex items-start gap-2">
<Icon {icon} class="shrink-0 text-xl text-[var(--color-accent)]" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium" {title}>{title}</p>
<p class="truncate text-[10px] text-[var(--color-ink-muted)]">{meta}</p>
</div>
{#if link}
<span
class="flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-medium
{link.sync_state === 'pending'
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
: 'bg-[var(--color-success)]/10 text-[var(--color-success)]'}"
title={link.sync_state === "pending"
? "Local changes not yet synced"
: "On this device and up to date"}
>
<Icon
icon={link.sync_state === "pending"
? "ph:cloud-arrow-up"
: "ph:cloud-check"}
class="text-[11px]"
/>
{link.sync_state === "pending" ? "Unsynced" : "Synced"}
</span>
{/if}
</div>
<div class="flex gap-1">
{#if link && onopen}
<button <button
class="absolute right-2 {offset} rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)]" class="flex flex-1 items-center justify-center gap-1 rounded-md bg-[var(--color-accent)] px-2 py-1.5 text-[10px] font-medium text-white transition hover:opacity-90"
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)} onclick={onopen}
>
<Icon icon="ph:pencil-simple" />
Open
</button>
{:else}
<button
class="flex flex-1 items-center justify-center gap-1 rounded-md border border-[var(--color-line)] px-2 py-1.5 text-[10px] transition hover:bg-[var(--color-surface-muted)]"
onclick={ondownload}
>
<Icon icon="ph:download-simple" />
Download
</button>
{/if}
{#if onremove}
<button
class="rounded-md border border-[var(--color-line)] px-2 py-1.5 text-[10px] text-[var(--color-ink-muted)] transition hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]"
onclick={onremove}
aria-label="Remove"
>
<Icon icon="ph:trash" />
</button>
{/if}
</div>
</div>
</div>
{/snippet}
{#snippet syncBadge(entry: BrowseEntry)}
{#if entry.cloud_linked}
<span class="flex shrink-0 items-center" title={syncLabel(entry)}>
{#if entry.sync_state === "pending"}
<Icon
icon="ph:cloud-arrow-up"
class="text-sm text-[var(--color-accent)]"
/>
{:else}
<Icon
icon="ph:cloud-check"
class="text-sm text-[var(--color-success)]"
/>
{/if}
</span>
{/if}
{/snippet}
{#snippet actions(entry: BrowseEntry)}
<button
data-card-menu
class="shrink-0 rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={(event) => {
event.stopPropagation();
if (menuFor === entry.path) {
menuFor = null;
return;
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
menuAt = { x: rect.right, y: rect.bottom + 4 };
menuFor = entry.path;
}}
aria-label="Actions" aria-label="Actions"
> >
<Icon icon="ph:dots-three-vertical" /> <Icon icon="ph:dots-three-vertical" />
@@ -151,7 +321,8 @@
{#if menuFor === entry.path} {#if menuFor === entry.path}
<div <div
class="absolute right-2 top-9 z-10 flex w-40 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg" class="fixed z-50 flex w-40 -translate-x-full flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
style="left: {menuAt.x}px; top: {menuAt.y}px"
> >
{#if entry.kind === "project" || entry.kind === "document"} {#if entry.kind === "project" || entry.kind === "document"}
<button <button
@@ -207,6 +378,14 @@
{/if} {/if}
{/snippet} {/snippet}
<svelte:window
on:click={(event) => {
if (!(event.target as HTMLElement).closest("[data-card-menu]")) {
menuFor = null;
}
}}
/>
<div class="flex h-full flex-col bg-[var(--color-surface-muted)]"> <div class="flex h-full flex-col bg-[var(--color-surface-muted)]">
<div <div
class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5" class="flex items-center gap-2 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-2.5"
@@ -350,12 +529,7 @@
> >
{entry.name} {entry.name}
</span> </span>
{#if entry.space_id} {@render syncBadge(entry)}
<Icon
icon="ph:cloud-check"
class="shrink-0 text-xs text-[var(--color-success)]"
/>
{/if}
</span> </span>
<span <span
class="block truncate text-[10px] text-[var(--color-ink-muted)]" class="block truncate text-[10px] text-[var(--color-ink-muted)]"
@@ -366,7 +540,7 @@
</span> </span>
</button> </button>
{@render actions(entry, "top-2")} {@render actions(entry)}
</div> </div>
{/each} {/each}
</div> </div>
@@ -413,23 +587,38 @@
{/if} {/if}
</span> </span>
<span class="flex flex-col gap-0.5 px-2.5 py-2"> </button>
<div class="flex items-center gap-1 px-2.5 py-2">
<button
class="flex min-w-0 flex-1 flex-col gap-0.5 text-left"
onclick={() => activate(entry)}
>
<span class="flex items-center gap-1.5">
<span <span
class="truncate text-xs font-medium" class="min-w-0 flex-1 truncate text-xs font-medium"
title={entry.name} title={entry.name}
> >
{entry.name} {entry.name}
</span> </span>
{@render syncBadge(entry)}
</span>
<span class="text-[10px] text-[var(--color-ink-muted)]"> <span class="text-[10px] text-[var(--color-ink-muted)]">
{#if entry.cloud_linked && entry.sync_state === "pending"}
Not synced
{:else if entry.cloud_linked}
Synced {relativeTime(entry.last_synced_at)}
{:else}
{formatSize(entry.size)} {formatSize(entry.size)}
{#if entry.modified} {#if entry.modified}
· {formatDate(entry.modified)} · {formatDate(entry.modified)}
{/if} {/if}
</span> {/if}
</span> </span>
</button> </button>
{@render actions(entry, "top-2")} {@render actions(entry)}
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -456,30 +645,33 @@
</button> </button>
</div> </div>
{:else} {:else}
<div class="mb-3 flex items-center gap-1 text-xs"> <div class="mb-4 flex items-center gap-2">
<button <button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)] class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder === null ? 'font-medium' : 'text-[var(--color-ink-muted)]'}" {app.cloudFolder !== 'shared'
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
: 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
onclick={() => openCloudFolder(null)} onclick={() => openCloudFolder(null)}
> >
<Icon icon="ph:cloud" /> <Icon icon="ph:cloud-fill" class="text-base" />
My Drive My Drive
</button> </button>
<button <button
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)] class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
{app.cloudFolder === 'shared' {app.cloudFolder === 'shared'
? 'font-medium' ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white shadow-sm'
: 'text-[var(--color-ink-muted)]'}" : 'border-[var(--color-line)] bg-[var(--color-surface)] text-[var(--color-ink-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-ink)]'}"
onclick={() => openCloudFolder("shared")} onclick={() => openCloudFolder("shared")}
> >
<Icon icon="ph:users-three" /> <Icon icon="ph:users-three-fill" class="text-base" />
Shared with me Shared with me
</button> </button>
{#if app.cloudLoading} {#if app.cloudLoading}
<Icon <Icon
icon="ph:circle-notch" icon="ph:circle-notch"
class="animate-spin text-[var(--color-accent)]" class="animate-spin text-base text-[var(--color-accent)]"
/> />
{/if} {/if}
</div> </div>
@@ -512,33 +704,66 @@
<div <div
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3" class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
> >
{#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}
</div>
{/if}
{#if app.cloudFiles.length > 0}
<h2
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
>
Images and fonts
</h2>
<div <div
class="flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]" class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
>
{#each app.cloudFiles as file (file.id)}
<div
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
> >
<Icon <Icon
icon="ph:file-text" icon={api.isImagePath(file.name)
class="text-xl text-[var(--color-accent)]" ? "ph:image"
: /\.(ttf|otf|ttc|otc)$/i.test(file.name)
? "ph:text-aa"
: "ph:file"}
class="shrink-0 text-xl text-[var(--color-accent)]"
/> />
<span class="truncate text-xs font-medium" title={document.title}> <div class="min-w-0 flex-1">
{document.title} <p class="truncate text-xs font-medium" title={file.name}>
</span> {file.name}
<span class="text-[10px] text-[var(--color-ink-muted)]"> </p>
{document.role} · {formatDate(document.updated_at)} <p class="truncate text-[10px] text-[var(--color-ink-muted)]">
</span> {formatDate(file.created_at)}
</p>
</div>
<button <button
class="mt-1 flex items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]" class="shrink-0 rounded-md border border-[var(--color-line)] p-1.5 text-[var(--color-ink-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
onclick={() => ondownloaddocument(document.id, document.title)} onclick={() => ondownloadfile(file.id, file.name)}
title="Add to shared assets on this device"
aria-label="Download"
> >
<Icon icon="ph:download-simple" /> <Icon icon="ph:download-simple" />
Download
</button> </button>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/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}
<div <div
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]" class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
> >
@@ -556,47 +781,18 @@
{/if} {/if}
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3"> <div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
{#each app.spaces as space (space.id)} {#each app.spaces as space (space.id)}
<div {@const linked = linkedSpace(space.id)}
class="group flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]" {@render cloudCard(
> "ph:folder-star",
<div class="flex items-center gap-2"> space.name,
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" /> linked
{#if localSpaceIds.has(space.id)} ? `Project · ${space.role}`
<span title="Downloaded to this device" class="flex"> : `${space.role} · ${formatDate(space.updated_at)}`,
<Icon linked,
icon="ph:hard-drives" linked ? () => openTarget(linked.path) : null,
class="text-sm text-[var(--color-success)]" () => onclonespace(space.id, space.name),
/> space.role === "owner" ? () => ondeletespace(space.id) : null,
</span> )}
{/if}
</div>
<span class="truncate text-xs font-medium">{space.name}</span>
<span class="text-[10px] text-[var(--color-ink-muted)]">
{space.role} · {formatDate(space.updated_at)}
</span>
<div class="mt-1 flex gap-1">
{#if !localSpaceIds.has(space.id)}
<button
class="flex flex-1 items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
onclick={() => onclonespace(space.id, space.name)}
>
<Icon icon="ph:download-simple" />
Download
</button>
{/if}
{#if space.role === "owner"}
<button
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] text-[var(--color-danger)] hover:bg-[var(--color-surface-muted)]"
onclick={() => ondeletespace(space.id)}
aria-label="Delete space"
>
<Icon icon="ph:trash" />
</button>
{/if}
</div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
+46
View File
@@ -13,6 +13,7 @@ export interface Settings {
export interface FileEntry { export interface FileEntry {
path: string; path: string;
name: string; name: string;
is_dir: boolean;
is_text: boolean; is_text: boolean;
size: number; size: number;
} }
@@ -96,6 +97,8 @@ export interface BrowseEntry {
space_id: string | null; space_id: string | null;
last_synced_at: string | null; last_synced_at: string | null;
child_count: number; child_count: number;
cloud_linked: boolean;
sync_state: "synced" | "pending" | null;
} }
export interface TargetInfo { export interface TargetInfo {
@@ -125,6 +128,15 @@ export const renameEntry = (path: string, newName: string) =>
export const deleteEntry = (path: string) => export const deleteEntry = (path: string) =>
invoke<void>("delete_entry", { path }); invoke<void>("delete_entry", { path });
export const moveEntry = (path: string, destination: string) =>
invoke<string>("move_entry", { path, destination });
export const duplicateEntry = (path: string) =>
invoke<string>("duplicate_entry", { path });
export const absolutePath = (path: string) =>
invoke<string>("absolute_path", { path });
export const uploadEntry = ( export const uploadEntry = (
parent: string, parent: string,
name: string, name: string,
@@ -288,6 +300,20 @@ export const cloudListDocuments = (folderId?: string | null) =>
export const cloudListShared = () => invoke<SharedItems>("cloud_list_shared"); export const cloudListShared = () => invoke<SharedItems>("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<CloudFile[]>("cloud_list_files", { folderId: folderId ?? null });
export const cloudDownloadFile = (fileId: string) =>
invoke<string>("cloud_download_file", { fileId });
export const cloudDownloadDocument = (documentId: string, parent: string) => export const cloudDownloadDocument = (documentId: string, parent: string) =>
invoke<string>("cloud_download_document", { documentId, parent }); invoke<string>("cloud_download_document", { documentId, parent });
@@ -300,6 +326,26 @@ export const cloudResolveDocument = (
serverHash: string, serverHash: string,
) => invoke<void>("cloud_resolve_document", { path, content, serverHash }); ) => invoke<void>("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<LinkedDocument[]>("cloud_linked_documents");
export const cloudLinkedSpaces = () =>
invoke<LinkedSpace[]>("cloud_linked_spaces");
export const cloudDocumentLink = (path: string) => export const cloudDocumentLink = (path: string) =>
invoke<DocumentLink | null>("cloud_document_link", { path }); invoke<DocumentLink | null>("cloud_document_link", { path });
+47 -3
View File
@@ -3,11 +3,14 @@ import type {
Account, Account,
BrowseEntry, BrowseEntry,
CloudDocument, CloudDocument,
CloudFile,
CloudFolder, CloudFolder,
CompileResult, CompileResult,
Conflict, Conflict,
Diagnostic, Diagnostic,
DocumentLink, DocumentLink,
LinkedDocument,
LinkedSpace,
Settings, Settings,
SpaceSummary, SpaceSummary,
TargetInfo, TargetInfo,
@@ -29,7 +32,10 @@ interface AppState {
cloudFolder: string | null | "shared"; cloudFolder: string | null | "shared";
cloudFolders: CloudFolder[]; cloudFolders: CloudFolder[];
cloudDocuments: CloudDocument[]; cloudDocuments: CloudDocument[];
cloudFiles: CloudFile[];
cloudLoading: boolean; cloudLoading: boolean;
linkedDocuments: LinkedDocument[];
linkedSpaces: LinkedSpace[];
documentLink: DocumentLink | null; documentLink: DocumentLink | null;
target: TargetInfo | null; target: TargetInfo | null;
@@ -60,7 +66,10 @@ export const app = $state<AppState>({
cloudFolder: null, cloudFolder: null,
cloudFolders: [], cloudFolders: [],
cloudDocuments: [], cloudDocuments: [],
cloudFiles: [],
cloudLoading: false, cloudLoading: false,
linkedDocuments: [],
linkedSpaces: [],
documentLink: null, documentLink: null,
target: null, target: null,
@@ -163,22 +172,28 @@ export async function refreshCloud() {
app.cloudLoading = true; app.cloudLoading = true;
try { try {
app.linkedDocuments = await api.cloudLinkedDocuments().catch(() => []);
app.linkedSpaces = await api.cloudLinkedSpaces().catch(() => []);
if (app.cloudFolder === "shared") { if (app.cloudFolder === "shared") {
const shared = await api.cloudListShared(); const shared = await api.cloudListShared();
app.cloudDocuments = shared.documents; app.cloudDocuments = shared.documents;
app.spaces = shared.spaces; app.spaces = shared.spaces;
app.cloudFolders = []; app.cloudFolders = [];
app.cloudFiles = [];
} else { } else {
const [folders, documents, spaces] = await Promise.all([ const [folders, documents, spaces, files] = await Promise.all([
api.cloudListFolders(), api.cloudListFolders(),
api.cloudListDocuments(app.cloudFolder), api.cloudListDocuments(app.cloudFolder),
api.cloudListSpaces(), api.cloudListSpaces(),
api.cloudListFiles(app.cloudFolder),
]); ]);
app.cloudFolders = folders.filter( app.cloudFolders = folders.filter(
(folder) => (folder.parent_id ?? null) === app.cloudFolder, (folder) => (folder.parent_id ?? null) === app.cloudFolder,
); );
app.cloudDocuments = documents; app.cloudDocuments = documents;
app.spaces = spaces; app.spaces = spaces;
app.cloudFiles = files;
} }
} catch (error) { } catch (error) {
setError(error); setError(error);
@@ -195,8 +210,7 @@ export async function openCloudFolder(id: string | null | "shared") {
export async function downloadDocument(documentId: string, title: string) { export async function downloadDocument(documentId: string, title: string) {
try { try {
const path = await api.cloudDownloadDocument(documentId, ""); const path = await api.cloudDownloadDocument(documentId, "");
app.scope = "local"; await refreshCloud();
await browseTo("");
setStatus(`Downloaded '${title}' to this device`); setStatus(`Downloaded '${title}' to this device`);
return path; return path;
} catch (error) { } 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) { export async function openTarget(path: string) {
try { try {
const target = await api.targetInfo(path); const target = await api.targetInfo(path);
+112 -40
View File
@@ -2,6 +2,7 @@
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import FileViewer from "$lib/components/FileViewer.svelte"; import FileViewer from "$lib/components/FileViewer.svelte";
@@ -32,6 +33,7 @@
clearMessages, clearMessages,
closeTarget, closeTarget,
compile, compile,
downloadCloudFile,
downloadDocument, downloadDocument,
openFile, openFile,
openTarget, openTarget,
@@ -39,6 +41,7 @@
refreshEntries, refreshEntries,
refreshSpaces, refreshSpaces,
refreshTarget, refreshTarget,
removeDownloadedDocument,
runSync, runSync,
saveAndCompile, saveAndCompile,
scheduleAutosave, scheduleAutosave,
@@ -58,8 +61,8 @@
| { kind: "new-space" } | { kind: "new-space" }
| { kind: "delete-space"; id: string } | { kind: "delete-space"; id: string }
| { kind: "clone-space"; id: string; name: string } | { kind: "clone-space"; id: string; name: string }
| { kind: "new-file" } | { kind: "new-file"; parent: string }
| { kind: "new-subfolder" } | { kind: "new-subfolder"; parent: string }
| { kind: "rename-file"; path: string } | { kind: "rename-file"; path: string }
| { kind: "delete-file"; path: string } | { kind: "delete-file"; path: string }
| { kind: "login" } | { kind: "login" }
@@ -72,6 +75,28 @@
let dialog = $state<Dialog>({ kind: "none" }); let dialog = $state<Dialog>({ kind: "none" });
let editorView = $state<EditorView | null>(null); let editorView = $state<EditorView | null>(null);
let imageViewer = $state<{ paths: string[]; index: number } | null>(null); let imageViewer = $state<{ paths: string[]; index: number } | null>(null);
let selectedEntry = $state<string | null>(null);
let selectedIsDir = $state(false);
let treeDropTarget = $state<string | null>(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( const activeFile = $derived(
app.target?.files.find((file) => file.path === app.activePath) ?? null, app.target?.files.find((file) => file.path === app.activePath) ?? null,
@@ -151,13 +176,17 @@
setStatus(`Downloaded '${name}' to this device`); setStatus(`Downloaded '${name}' to this device`);
}); });
async function importFiles() { async function importFiles(folder?: string) {
const sources = await pickFiles("all"); const sources = await pickFiles("all");
if (sources.length === 0) return; if (sources.length === 0) return;
try { try {
if (app.view === "editor" && app.target) { 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 refreshTarget();
await compile(); await compile();
setStatus(`Imported ${imported.length} file(s)`); setStatus(`Imported ${imported.length} file(s)`);
@@ -171,20 +200,47 @@
} }
} }
const createFileInTarget = (name: string) => const createFileInTarget = (parent: string, name: string) =>
guard(async () => { 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 api.writeTargetFile(app.target!.path, path, "");
await refreshTarget(); await refreshTarget();
await openFile(path); await openFile(path);
}); });
const createFolderInTarget = (path: string) => const createFolderInTarget = (parent: string, name: string) =>
guard(async () => { guard(async () => {
await api.createFolderEntry(app.target!.path, path); await api.createFolderEntry(app.target!.path, joinInTarget(parent, name));
await refreshTarget(); 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) => const renameFile = (path: string, next: string) =>
guard(async () => { guard(async () => {
const payload = await api.readTargetFile(app.target!.path, path); const payload = await api.readTargetFile(app.target!.path, path);
@@ -283,14 +339,27 @@
return null; 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(); const destination = dropDestination();
if (!destination || paths.length === 0) return; if (!destination || paths.length === 0) return;
try { try {
const imported = const imported =
destination.kind === "target" destination.kind === "target"
? await api.importIntoTarget(destination.path, paths) ? await api.importIntoFolder(
folder ? targetChild(folder) : destination.path,
paths,
)
: await api.importIntoFolder(destination.path, paths); : await api.importIntoFolder(destination.path, paths);
if (destination.kind === "target") { if (destination.kind === "target") {
@@ -309,11 +378,21 @@
const pending = getCurrentWebview().onDragDropEvent((event) => { const pending = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "over") { if (event.payload.type === "over") {
dropActive = dropDestination() !== null; dropActive = dropDestination() !== null;
treeDropTarget =
app.view === "editor"
? folderUnderPointer(
event.payload.position.x,
event.payload.position.y,
)
: null;
} else if (event.payload.type === "drop") { } else if (event.payload.type === "drop") {
const folder = treeDropTarget;
dropActive = false; dropActive = false;
dropPaths(event.payload.paths); treeDropTarget = null;
dropPaths(event.payload.paths, folder);
} else { } else {
dropActive = false; dropActive = false;
treeDropTarget = null;
} }
}); });
@@ -485,6 +564,8 @@
onviewimage={(paths, index) => (imageViewer = { paths, index })} onviewimage={(paths, index) => (imageViewer = { paths, index })}
ondownloaddocument={(documentId, title) => ondownloaddocument={(documentId, title) =>
downloadDocument(documentId, title)} downloadDocument(documentId, title)}
onremovedownload={removeDownloadedDocument}
ondownloadfile={downloadCloudFile}
onnewspace={() => (dialog = { kind: "new-space" })} onnewspace={() => (dialog = { kind: "new-space" })}
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })} onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
ondeletespace={(id) => (dialog = { kind: "delete-space", id })} 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" class="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5"
> >
<span <span
class="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]" class="truncate text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
> >
Files {selectedFolder ? selectedFolder : "Files"}
</span> </span>
<div class="flex gap-0.5">
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "new-file" })}
aria-label="New file"
>
<Icon icon="ph:file-plus" />
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={() => (dialog = { kind: "new-subfolder" })}
aria-label="New folder"
>
<Icon icon="ph:folder-plus" />
</button>
<button
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
onclick={importFiles}
aria-label="Import files from this computer"
>
<Icon icon="ph:upload-simple" />
</button>
</div>
</div> </div>
<FileTree <FileTree
files={app.target?.files ?? []} files={app.target?.files ?? []}
activePath={app.activePath} activePath={app.activePath}
entrypoint={app.target?.entrypoint ?? "main.typ"} entrypoint={app.target?.entrypoint ?? "main.typ"}
selected={selectedEntry}
dropTarget={treeDropTarget}
onopen={openFile} onopen={openFile}
onselect={(path, isDir) => {
selectedEntry = path;
selectedIsDir = isDir;
}}
onrename={(path) => (dialog = { kind: "rename-file", path })} onrename={(path) => (dialog = { kind: "rename-file", path })}
ondelete={(path) => (dialog = { kind: "delete-file", path })} ondelete={(path) => (dialog = { kind: "delete-file", path })}
onduplicate={duplicateInTarget}
onreveal={revealInTarget}
onsetentry={setEntrypoint} onsetentry={setEntrypoint}
onmove={moveInTarget}
onnewfile={(parent) => (dialog = { kind: "new-file", parent })}
onnewfolder={(parent) => (dialog = { kind: "new-subfolder", parent })}
onimport={(parent) => importFiles(parent)}
/> />
</div> </div>
{/if} {/if}
@@ -718,21 +788,23 @@
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "new-file"} {:else if dialog.kind === "new-file"}
{@const target = dialog}
<PromptModal <PromptModal
title="New file" title={target.parent ? `New file in ${target.parent}` : "New file"}
label="File name" label="File name"
icon="ph:file-plus" icon="ph:file-plus"
placeholder="chapter-1" placeholder="chapter-1"
onsubmit={createFileInTarget} onsubmit={(name) => createFileInTarget(target.parent, name)}
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "new-subfolder"} {:else if dialog.kind === "new-subfolder"}
{@const target = dialog}
<PromptModal <PromptModal
title="New folder" title={target.parent ? `New folder in ${target.parent}` : "New folder"}
label="Folder name" label="Folder name"
icon="ph:folder-plus" icon="ph:folder-plus"
placeholder="figures" placeholder="figures"
onsubmit={createFolderInTarget} onsubmit={(name) => createFolderInTarget(target.parent, name)}
onclose={close} onclose={close}
/> />
{:else if dialog.kind === "rename-file"} {:else if dialog.kind === "rename-file"}