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 <[email protected]>
This commit is contained in:
+18
-1
@@ -68,6 +68,7 @@ struct Summary {
|
||||
urls: Vec<String>,
|
||||
favorite: bool,
|
||||
has_totp: bool,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -79,6 +80,7 @@ struct Detail {
|
||||
urls: Vec<String>,
|
||||
totp: Option<String>,
|
||||
notes: Option<String>,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -88,6 +90,8 @@ struct NewLogin {
|
||||
password: Option<String>,
|
||||
urls: Vec<String>,
|
||||
totp: Option<String>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -228,6 +232,7 @@ fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
|
||||
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<Detail> {
|
||||
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<String>) -> 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<String> {
|
||||
app.core
|
||||
@@ -259,7 +275,7 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
||||
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,
|
||||
|
||||
+71
-9
@@ -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<void>) | 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<string, string>();
|
||||
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<ItemSummary[]>("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<string>("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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<string, Shape[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user