From e2a3c4782541a4cb05223df5200329387c50e88c Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 24 Oct 2026 15:11:05 +0700 Subject: [PATCH] Tags on the desktop app A Tags group in the sidebar filters the list, the item page shows its tags with an Edit tags action, new logins take tags, and search matches tags. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/main.rs | 19 +++++++++- src/main.ts | 80 ++++++++++++++++++++++++++++++++++++++----- src/style.css | 5 +++ src/ui/kv.ts | 1 + 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8413a18..bb57a1c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -68,6 +68,7 @@ struct Summary { urls: Vec, favorite: bool, has_totp: bool, + tags: Vec, } #[derive(Serialize)] @@ -79,6 +80,7 @@ struct Detail { urls: Vec, totp: Option, notes: Option, + tags: Vec, } #[derive(Deserialize)] @@ -88,6 +90,8 @@ struct NewLogin { password: Option, urls: Vec, totp: Option, + #[serde(default)] + tags: Vec, } #[derive(Serialize)] @@ -228,6 +232,7 @@ fn list_items(app: State<'_, App>) -> CmdResult> { urls: s.urls, favorite: s.favorite, has_totp: s.has_totp, + tags: s.tags, }) .collect()) } @@ -243,9 +248,20 @@ fn get_item(app: State<'_, App>, id: String) -> CmdResult { urls: d.urls, totp: d.totp, notes: d.notes, + tags: d.tags, }) } +/// Replaces an item's tags (the core normalises them); everything else about the item stays. +#[tauri::command] +fn set_tags(app: State<'_, App>, id: String, tags: Vec) -> CmdResult<()> { + let mut item = app.core.get_item(id).map_err(err)?; + item.tags = tags; + app.core.save_item(item).map_err(err)?; + app.sync.request_sync(); + Ok(()) +} + #[tauri::command] fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult { app.core @@ -259,7 +275,7 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult { totp: item.totp.filter(|t| !t.trim().is_empty()), notes: None, favorite: false, - tags: vec![], + tags: item.tags, modified_at: 0, }) .map_err(err) @@ -684,6 +700,7 @@ fn main() { revoke_pairing, pending_saves, pending_passkeys, + set_tags, resolve_passkey, resolve_save, sync_status, diff --git a/src/main.ts b/src/main.ts index bf54e23..3ab3d9d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,8 @@ import { } from "./ui/kv"; interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number } -interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean } -interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null } +interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean; tags: string[] } +interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null; tags: string[] } interface PairingView { id: string; name: string; caller: string; words: string[] } interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number } interface SaveView { id: string; origin: string; username: string; update: boolean } @@ -448,7 +448,38 @@ let refreshList: (() => Promise) | null = null; let syncBox: HTMLElement | null = null; let syncPill: HTMLElement | null = null; let alarmBox: HTMLElement | null = null; -type Category = "all" | "favorites"; +type Category = "all" | "favorites" | `tag:${string}`; + +/** "work, #Finance, family trip" → ["work", "Finance", "family trip"] (the core normalises them again). */ +function parseTags(text: string): string[] { + const out: string[] = []; + for (const part of text.split(",")) { + const t = part.trim().replace(/^#+/, "").split(/\s+/).filter(Boolean).join(" "); + if (t && !out.some((o) => o.toLowerCase() === t.toLowerCase())) out.push(t); + } + return out; +} + +/** Every tag in the vault, once, sorted. */ +function allTags(items: ItemSummary[]): string[] { + const seen = new Map(); + for (const t of items.flatMap((i) => i.tags)) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t); + return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); +} + +function inCategory(it: ItemSummary, c: Category): boolean { + if (c === "all") return true; + if (c === "favorites") return it.favorite; + const tag = c.slice(4).toLowerCase(); + return it.tags.some((t) => t.toLowerCase() === tag); +} + +/** Title, username or tag; "#work" matches tags only. */ +function matches(it: ItemSummary, q: string): boolean { + if (!q) return true; + if (q.startsWith("#")) return it.tags.some((t) => t.toLowerCase().includes(q.slice(1))); + return it.title.toLowerCase().includes(q) || (it.username ?? "").toLowerCase().includes(q) || it.tags.some((t) => t.toLowerCase().includes(q)); +} let category: Category = "all"; let selectedId: string | null = null; @@ -462,7 +493,7 @@ async function renderVault(state: AppState) { let items: ItemSummary[] = []; const draw = () => { const q = search.value.trim().toLowerCase(); - const shown = items.filter((it) => (category === "all" || it.favorite) && (!q || it.title.toLowerCase().includes(q) || (it.username ?? "").toLowerCase().includes(q))); + const shown = items.filter((it) => inCategory(it, category) && matches(it, q)); list.replaceChildren(); for (const it of shown) { const row = itemRow(it.title, it.username ?? "", { favorite: it.favorite, selected: it.id === selectedId }); @@ -484,12 +515,17 @@ async function renderVault(state: AppState) { const fill = async () => { items = await invoke("list_items"); if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null; + drawTags(); draw(); }; search.oninput = draw; - await fill(); - refreshList = fill; + const tagsBox = h("div", "rail-tags"); + const drawTags = () => { + const tags = allTags(items); + if (category.startsWith("tag:") && !tags.some((t) => `tag:${t.toLowerCase()}` === category.toLowerCase())) category = "all"; + tagsBox.replaceChildren(...(tags.length ? [h("div", "rail-h kv-label", "Tags"), ...tags.map((t) => cat(`tag:${t}`, `#${t}`, "tag"))] : [])); + }; const cat = (c: Category, label: string, ic: IconName) => { const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label)); b.type = "button"; @@ -511,8 +547,10 @@ async function renderVault(state: AppState) { }; syncBox = h("div", "rail-foot"); const rail = h("nav", "rail", h("div", "rail-h kv-label", "Vault"), cat("all", "All items", "key"), cat("favorites", "Favourites", "star"), - h("div", "rail-h kv-label", "Manage"), settings, h("span", "grow"), syncBox); + tagsBox, h("div", "rail-h kv-label", "Manage"), settings, h("span", "grow"), syncBox); rail.setAttribute("aria-label", "Categories"); + await fill(); + refreshList = fill; if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox); const add = button("New item", "quiet", () => { @@ -639,25 +677,49 @@ async function renderDetail(target: HTMLElement, id: string) { if (item.notes) rows.push(fieldRow("Notes", h("span", "notes", item.notes))); const del = button("Delete", "danger", () => confirmModal(`Delete “${item.title}”?`, [p("kv-muted", "It's removed from every device that syncs with your server.")], "Delete", async () => { await invoke("delete_item", { id }); selectedId = null; await render(); }, true)); + const editTags = button(item.tags.length ? "Edit tags" : "Add tags", "quiet", () => tagsModal(item)); + editTags.prepend(icon("tag", "sm")); target.replaceChildren( h("div", "detail-head", tile(item.title), h("h2", "kv-title-2 grow", item.title), del), + h("div", "tag-row", ...item.tags.map((t) => h("span", "tag", `#${t}`)), editTags), h("div", "kv-card fields", ...rows), ); } +function tagsModal(item: ItemDto) { + const tags = input({ value: item.tags.join(", "), placeholder: "work, finance", spellcheck: false }); + const err = errBox(); + const save = button("Save", "primary", async () => { + try { + await invoke("set_tags", { id: item.id, tags: parseTags(tags.value) }); + closeModal(); + await render(); + } catch (e) { + setErr(err, e); + } + }); + tags.onkeydown = (e) => { + if (e.key === "Enter") save.click(); + }; + openModal(h("h2", "kv-title-2", `Tags for ${item.title}`), field("Tags", tags, "Separate tags with commas."), err, + h("div", "actions end", button("Cancel", "secondary", closeModal), save)); + tags.focus(); +} + function renderEditor(target: HTMLElement) { const title = input({ autocomplete: "off" }); const user = input({ autocomplete: "off", spellcheck: false }); const pass = input({ autocomplete: "off" }, true); const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false }); const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true); + const tags = input({ placeholder: "work, finance", spellcheck: false }); const gen = button("Generate", "quiet", async () => { pass.value = await invoke("generate_password", { length: 24 }); }); const err = errBox(); const save = button("Save", "primary"); save.onclick = async () => { try { await invoke("save_item", { - item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null }, + item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null, tags: parseTags(tags.value) }, }); await render(); } catch (e) { @@ -668,7 +730,7 @@ function renderEditor(target: HTMLElement) { h("h2", "kv-title-2", "New login"), field("Title", title), field("Username", user), h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)), - field("Website", url), field("One-time code", totp, "Optional."), + field("Website", url), field("One-time code", totp, "Optional."), field("Tags", tags, "Optional. Separate tags with commas."), err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail()))))); title.focus(); } diff --git a/src/style.css b/src/style.css index 8a8b08d..a8cab6c 100644 --- a/src/style.css +++ b/src/style.css @@ -99,3 +99,8 @@ summary { cursor: pointer; color: var(--text-2); } .prompt { animation: pop 160ms var(--ease); } } @keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.985); } } + +/* Tags: the item page's labels and the sidebar's Tags group. */ +.tag-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1\.5); margin: 0 0 var(--space-3); } +.tag-row .tag { font: 500 12px/16px var(--font-sans); color: var(--text-2); background: var(--surface-2); border-radius: var(--radius-sm); padding: 4px 8px; } +.rail-tags { display: contents; } diff --git a/src/ui/kv.ts b/src/ui/kv.ts index 82697cd..a450e6e 100644 --- a/src/ui/kv.ts +++ b/src/ui/kv.ts @@ -41,6 +41,7 @@ const ICONS = { ], laptop: [["path", { d: "M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16" }]], link: [["path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }], ["path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }]], + tag: [["path", { d: "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" }], ["circle", { cx: "7.5", cy: "7.5", r: ".5" }]], list: [["path", { d: "M3 12h.01" }], ["path", { d: "M3 18h.01" }], ["path", { d: "M3 6h.01" }], ["path", { d: "M8 12h13" }], ["path", { d: "M8 18h13" }], ["path", { d: "M8 6h13" }]], } satisfies Record;