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
+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();
}