Tags on the desktop app
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

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:
2026-10-24 15:11:05 +07:00
co-authored by Claude Opus 5.5
parent 6f06b1e631
commit e2a3c47825
4 changed files with 95 additions and 10 deletions
+18 -1
View File
@@ -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
View File
@@ -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();
}
+5
View File
@@ -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; }
+1
View File
@@ -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[]>;