Show cloud files and sync state in the cloud workspace
This commit is contained in:
@@ -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"
|
||||
|
||||
+57
-6
@@ -16,6 +16,7 @@ pub struct DocumentLink {
|
||||
pub base_hash: String,
|
||||
pub role: String,
|
||||
pub base_content: String,
|
||||
pub synced_at: Option<String>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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<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> {
|
||||
self.with(|connection| {
|
||||
connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?;
|
||||
|
||||
@@ -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<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,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<Thumbnail
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_project = full.is_dir();
|
||||
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());
|
||||
}
|
||||
|
||||
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)? {
|
||||
return Ok(Thumbnail { kind, data });
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
app,
|
||||
breadcrumbs,
|
||||
browseTo,
|
||||
linkedDocument,
|
||||
linkedSpace,
|
||||
openCloudFolder,
|
||||
openTarget,
|
||||
refreshCloud,
|
||||
@@ -21,6 +23,8 @@
|
||||
onlink: (entry: BrowseEntry) => 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<string | null>(null);
|
||||
let menuAt = $state({ x: 0, y: 0 });
|
||||
|
||||
const trail = $derived(breadcrumbs());
|
||||
|
||||
@@ -66,6 +73,34 @@
|
||||
);
|
||||
|
||||
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(() => {
|
||||
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> = {
|
||||
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 @@
|
||||
}
|
||||
</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
|
||||
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={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
|
||||
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)]"
|
||||
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
|
||||
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"
|
||||
>
|
||||
<Icon icon="ph:dots-three-vertical" />
|
||||
@@ -151,7 +321,8 @@
|
||||
|
||||
{#if menuFor === entry.path}
|
||||
<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"}
|
||||
<button
|
||||
@@ -207,6 +378,14 @@
|
||||
{/if}
|
||||
{/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 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}
|
||||
</span>
|
||||
{#if entry.space_id}
|
||||
<Icon
|
||||
icon="ph:cloud-check"
|
||||
class="shrink-0 text-xs text-[var(--color-success)]"
|
||||
/>
|
||||
{/if}
|
||||
{@render syncBadge(entry)}
|
||||
</span>
|
||||
<span
|
||||
class="block truncate text-[10px] text-[var(--color-ink-muted)]"
|
||||
@@ -366,7 +540,7 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
{@render actions(entry)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -413,23 +587,38 @@
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="flex flex-col gap-0.5 px-2.5 py-2">
|
||||
<span
|
||||
class="truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(entry.size)}
|
||||
{#if entry.modified}
|
||||
· {formatDate(entry.modified)}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry, "top-2")}
|
||||
<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
|
||||
class="min-w-0 flex-1 truncate text-xs font-medium"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
{@render syncBadge(entry)}
|
||||
</span>
|
||||
<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)}
|
||||
{#if entry.modified}
|
||||
· {formatDate(entry.modified)}
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{@render actions(entry)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -456,30 +645,33 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-3 flex items-center gap-1 text-xs">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||
{app.cloudFolder === null ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
class="flex items-center gap-2 rounded-lg border px-3.5 py-2 text-xs font-medium transition
|
||||
{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)}
|
||||
>
|
||||
<Icon icon="ph:cloud" />
|
||||
<Icon icon="ph:cloud-fill" class="text-base" />
|
||||
My Drive
|
||||
</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'
|
||||
? 'font-medium'
|
||||
: 'text-[var(--color-ink-muted)]'}"
|
||||
? '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("shared")}
|
||||
>
|
||||
<Icon icon="ph:users-three" />
|
||||
<Icon icon="ph:users-three-fill" class="text-base" />
|
||||
Shared with me
|
||||
</button>
|
||||
|
||||
{#if app.cloudLoading}
|
||||
<Icon
|
||||
icon="ph:circle-notch"
|
||||
class="animate-spin text-[var(--color-accent)]"
|
||||
class="animate-spin text-base text-[var(--color-accent)]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -512,33 +704,66 @@
|
||||
<div
|
||||
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
|
||||
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
|
||||
>
|
||||
{#each app.cloudFiles as file (file.id)}
|
||||
<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="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="ph:file-text"
|
||||
class="text-xl text-[var(--color-accent)]"
|
||||
icon={api.isImagePath(file.name)
|
||||
? "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}>
|
||||
{document.title}
|
||||
</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{document.role} · {formatDate(document.updated_at)}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p class="truncate text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatDate(file.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<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)]"
|
||||
onclick={() => ondownloaddocument(document.id, document.title)}
|
||||
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={() => ondownloadfile(file.id, file.name)}
|
||||
title="Add to shared assets on this device"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Icon icon="ph:download-simple" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/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
|
||||
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
@@ -556,47 +781,18 @@
|
||||
{/if}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||
{#each app.spaces as space (space.id)}
|
||||
<div
|
||||
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)]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="ph:cloud" class="text-xl text-[var(--color-accent)]" />
|
||||
{#if localSpaceIds.has(space.id)}
|
||||
<span title="Downloaded to this device" class="flex">
|
||||
<Icon
|
||||
icon="ph:hard-drives"
|
||||
class="text-sm text-[var(--color-success)]"
|
||||
/>
|
||||
</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>
|
||||
{@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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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<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 = (
|
||||
parent: string,
|
||||
name: string,
|
||||
@@ -288,6 +300,20 @@ export const cloudListDocuments = (folderId?: string | null) =>
|
||||
|
||||
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) =>
|
||||
invoke<string>("cloud_download_document", { documentId, parent });
|
||||
|
||||
@@ -300,6 +326,26 @@ export const cloudResolveDocument = (
|
||||
serverHash: string,
|
||||
) => 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) =>
|
||||
invoke<DocumentLink | null>("cloud_document_link", { path });
|
||||
|
||||
|
||||
@@ -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<AppState>({
|
||||
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);
|
||||
|
||||
+112
-40
@@ -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<Dialog>({ kind: "none" });
|
||||
let editorView = $state<EditorView | 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(
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<FileTree
|
||||
files={app.target?.files ?? []}
|
||||
activePath={app.activePath}
|
||||
entrypoint={app.target?.entrypoint ?? "main.typ"}
|
||||
selected={selectedEntry}
|
||||
dropTarget={treeDropTarget}
|
||||
onopen={openFile}
|
||||
onselect={(path, isDir) => {
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -718,21 +788,23 @@
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-file"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="New file"
|
||||
title={target.parent ? `New file in ${target.parent}` : "New file"}
|
||||
label="File name"
|
||||
icon="ph:file-plus"
|
||||
placeholder="chapter-1"
|
||||
onsubmit={createFileInTarget}
|
||||
onsubmit={(name) => createFileInTarget(target.parent, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "new-subfolder"}
|
||||
{@const target = dialog}
|
||||
<PromptModal
|
||||
title="New folder"
|
||||
title={target.parent ? `New folder in ${target.parent}` : "New folder"}
|
||||
label="Folder name"
|
||||
icon="ph:folder-plus"
|
||||
placeholder="figures"
|
||||
onsubmit={createFolderInTarget}
|
||||
onsubmit={(name) => createFolderInTarget(target.parent, name)}
|
||||
onclose={close}
|
||||
/>
|
||||
{:else if dialog.kind === "rename-file"}
|
||||
|
||||
Reference in New Issue
Block a user