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>,
|
urls: Vec<String>,
|
||||||
favorite: bool,
|
favorite: bool,
|
||||||
has_totp: bool,
|
has_totp: bool,
|
||||||
|
tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -79,6 +80,7 @@ struct Detail {
|
|||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
notes: Option<String>,
|
notes: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -88,6 +90,8 @@ struct NewLogin {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
totp: Option<String>,
|
totp: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -228,6 +232,7 @@ fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
|
|||||||
urls: s.urls,
|
urls: s.urls,
|
||||||
favorite: s.favorite,
|
favorite: s.favorite,
|
||||||
has_totp: s.has_totp,
|
has_totp: s.has_totp,
|
||||||
|
tags: s.tags,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -243,9 +248,20 @@ fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
|
|||||||
urls: d.urls,
|
urls: d.urls,
|
||||||
totp: d.totp,
|
totp: d.totp,
|
||||||
notes: d.notes,
|
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]
|
#[tauri::command]
|
||||||
fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
||||||
app.core
|
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()),
|
totp: item.totp.filter(|t| !t.trim().is_empty()),
|
||||||
notes: None,
|
notes: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tags: vec![],
|
tags: item.tags,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
})
|
})
|
||||||
.map_err(err)
|
.map_err(err)
|
||||||
@@ -684,6 +700,7 @@ fn main() {
|
|||||||
revoke_pairing,
|
revoke_pairing,
|
||||||
pending_saves,
|
pending_saves,
|
||||||
pending_passkeys,
|
pending_passkeys,
|
||||||
|
set_tags,
|
||||||
resolve_passkey,
|
resolve_passkey,
|
||||||
resolve_save,
|
resolve_save,
|
||||||
sync_status,
|
sync_status,
|
||||||
|
|||||||
+71
-9
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "./ui/kv";
|
} from "./ui/kv";
|
||||||
|
|
||||||
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
|
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 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 }
|
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 PairingView { id: string; name: string; caller: string; words: string[] }
|
||||||
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
||||||
interface SaveView { id: string; origin: string; username: string; update: boolean }
|
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 syncBox: HTMLElement | null = null;
|
||||||
let syncPill: HTMLElement | null = null;
|
let syncPill: HTMLElement | null = null;
|
||||||
let alarmBox: 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 category: Category = "all";
|
||||||
let selectedId: string | null = null;
|
let selectedId: string | null = null;
|
||||||
|
|
||||||
@@ -462,7 +493,7 @@ async function renderVault(state: AppState) {
|
|||||||
let items: ItemSummary[] = [];
|
let items: ItemSummary[] = [];
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
const q = search.value.trim().toLowerCase();
|
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();
|
list.replaceChildren();
|
||||||
for (const it of shown) {
|
for (const it of shown) {
|
||||||
const row = itemRow(it.title, it.username ?? "", { favorite: it.favorite, selected: it.id === selectedId });
|
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 () => {
|
const fill = async () => {
|
||||||
items = await invoke<ItemSummary[]>("list_items");
|
items = await invoke<ItemSummary[]>("list_items");
|
||||||
if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null;
|
if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null;
|
||||||
|
drawTags();
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
search.oninput = 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 cat = (c: Category, label: string, ic: IconName) => {
|
||||||
const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label));
|
const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label));
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
@@ -511,8 +547,10 @@ async function renderVault(state: AppState) {
|
|||||||
};
|
};
|
||||||
syncBox = h("div", "rail-foot");
|
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"),
|
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");
|
rail.setAttribute("aria-label", "Categories");
|
||||||
|
await fill();
|
||||||
|
refreshList = fill;
|
||||||
if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox);
|
if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox);
|
||||||
|
|
||||||
const add = button("New item", "quiet", () => {
|
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)));
|
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",
|
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));
|
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(
|
target.replaceChildren(
|
||||||
h("div", "detail-head", tile(item.title), h("h2", "kv-title-2 grow", item.title), del),
|
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),
|
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) {
|
function renderEditor(target: HTMLElement) {
|
||||||
const title = input({ autocomplete: "off" });
|
const title = input({ autocomplete: "off" });
|
||||||
const user = input({ autocomplete: "off", spellcheck: false });
|
const user = input({ autocomplete: "off", spellcheck: false });
|
||||||
const pass = input({ autocomplete: "off" }, true);
|
const pass = input({ autocomplete: "off" }, true);
|
||||||
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
|
const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false });
|
||||||
const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true);
|
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 gen = button("Generate", "quiet", async () => { pass.value = await invoke<string>("generate_password", { length: 24 }); });
|
||||||
const err = errBox();
|
const err = errBox();
|
||||||
const save = button("Save", "primary");
|
const save = button("Save", "primary");
|
||||||
save.onclick = async () => {
|
save.onclick = async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("save_item", {
|
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();
|
await render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -668,7 +730,7 @@ function renderEditor(target: HTMLElement) {
|
|||||||
h("h2", "kv-title-2", "New login"),
|
h("h2", "kv-title-2", "New login"),
|
||||||
field("Title", title), field("Username", user),
|
field("Title", title), field("Username", user),
|
||||||
h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)),
|
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())))));
|
err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail())))));
|
||||||
title.focus();
|
title.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,3 +99,8 @@ summary { cursor: pointer; color: var(--text-2); }
|
|||||||
.prompt { animation: pop 160ms var(--ease); }
|
.prompt { animation: pop 160ms var(--ease); }
|
||||||
}
|
}
|
||||||
@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.985); } }
|
@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" }]],
|
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" }]],
|
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" }]],
|
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[]>;
|
} satisfies Record<string, Shape[]>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user