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-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
@@ -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])?;
|
||||||
|
|||||||
@@ -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,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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
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
|
<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)]"
|
data-card-menu
|
||||||
onclick={() => (menuFor = menuFor === entry.path ? null : entry.path)}
|
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">
|
|
||||||
<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>
|
</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>
|
</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
|
||||||
|
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
|
||||||
|
>
|
||||||
|
{#each app.cloudFiles as file (file.id)}
|
||||||
<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="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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user