Rail and drawer: only the 5 tags most recently used or viewed (#5931)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 1m9s
CI & Build / integration (push) Successful in 2m10s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Failing after 6m3s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m5s
Desktop (Tauri) / Update manifest (push) Successful in 4s
A long tag list pushed the drawer's Archive, Trash and Sync off screen on Android, where Tags still sat above them (#5915 only reordered the web rail). Tags now come last on Android too, and both clients list the five tags most recently used or viewed, by name, with "All tags (N)" opening the rest in place. Used: the newest untrashed note carrying the tag. GET /api/labels and the core's list_labels return it as last_used, and each client also folds in the notes it holds, so a tag applied a moment ago counts before the next listing. Viewed: opening the tag's lens, remembered on the device (per user on the web). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useReminderStore } from "../stores/reminders";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
@@ -16,12 +17,20 @@ import ShareDialog from "./ShareDialog.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import { useLensName } from "../composables/useLensName";
|
||||
import { labelDotClasses } from "../notes/colors";
|
||||
import {
|
||||
RECENT_TAG_COUNT,
|
||||
loadTagViews,
|
||||
recentTags,
|
||||
recordTagView,
|
||||
type TagViews,
|
||||
} from "../notes/recentTags";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const config = useConfigStore();
|
||||
const labels = useLabelsStore();
|
||||
const notes = useNotesStore();
|
||||
const reminders = useReminderStore();
|
||||
const ui = useUiStore();
|
||||
// Sync is a desktop-app concern: the web build already IS the server's UI.
|
||||
@@ -45,6 +54,23 @@ const buildLabel = computed(
|
||||
);
|
||||
|
||||
const managing = ref(false);
|
||||
|
||||
// The rail lists the few tags in use, not all of them (#5931); "All tags" opens the
|
||||
// rest in place, below everything else, so nothing is pushed out of view.
|
||||
const viewer = computed(() => session.user?.id ?? "local");
|
||||
const tagViews = ref<TagViews>({});
|
||||
const allTags = ref(false);
|
||||
watch(viewer, (id) => (tagViews.value = loadTagViews(id)), { immediate: true });
|
||||
watch(
|
||||
() => (route.name === "label" ? String(route.params.id) : null),
|
||||
(id) => {
|
||||
if (id) tagViews.value = recordTagView(viewer.value, tagViews.value, id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const railTags = computed(() =>
|
||||
allTags.value ? labels.items : recentTags(labels.items, notes.items, tagViews.value),
|
||||
);
|
||||
const showShortcuts = ref(false);
|
||||
const paletteOpen = ref(false);
|
||||
const drawer = ref(false);
|
||||
@@ -419,7 +445,8 @@ async function signOut() {
|
||||
<ImportNotes />
|
||||
|
||||
<!-- Tags last: it is the one list here that grows without limit, so
|
||||
everything with a fixed place sits above it and stays put. -->
|
||||
everything with a fixed place sits above it and stays put. And it
|
||||
shows only the few tags in use (#5931), the rest one press away. -->
|
||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||
<span class="section-label">Tags</span>
|
||||
<button
|
||||
@@ -434,7 +461,7 @@ async function signOut() {
|
||||
</div>
|
||||
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
|
||||
<RouterLink
|
||||
v-for="lb in labels.items"
|
||||
v-for="lb in railTags"
|
||||
:key="lb.id"
|
||||
:to="`/label/${lb.id}`"
|
||||
class="nav-link"
|
||||
@@ -446,6 +473,15 @@ async function signOut() {
|
||||
></span>
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="labels.items.length > RECENT_TAG_COUNT"
|
||||
type="button"
|
||||
class="nav-link text-xs text-neutral-500 dark:text-neutral-400"
|
||||
:aria-expanded="allTags"
|
||||
@click.stop="allTags = !allTags"
|
||||
>
|
||||
{{ allTags ? "Fewer tags" : `All tags (${labels.items.length})` }}
|
||||
</button>
|
||||
|
||||
<!-- Account, settings and sign-out, for the screens where they are NOT in
|
||||
the header. Four icons plus a search field never fit one phone-width
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { recentTags } from "./recentTags";
|
||||
|
||||
const tag = (id: string, last_used: string | null = null) => ({ id, name: id, last_used });
|
||||
const names = (tags: { name: string }[]) => tags.map((t) => t.name);
|
||||
|
||||
describe("the rail's recent tags", () => {
|
||||
it("shows every tag while there are no more than five", () => {
|
||||
const tags = ["c", "a", "b"].map((id) => tag(id));
|
||||
expect(recentTags(tags, [], {})).toBe(tags);
|
||||
});
|
||||
|
||||
it("keeps the five used most recently, listed by name", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f", "g"].map((id, i) =>
|
||||
tag(id, `2026-10-0${i + 1}T00:00:00Z`),
|
||||
);
|
||||
expect(names(recentTags(tags, [], {}))).toEqual(["c", "d", "e", "f", "g"]);
|
||||
});
|
||||
|
||||
it("counts opening a tag as using it", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
|
||||
tags[0].last_used = "2026-01-01T00:00:00Z";
|
||||
const viewed = { a: Date.parse("2026-10-10T00:00:00Z") };
|
||||
expect(names(recentTags(tags, [], viewed))).toContain("a");
|
||||
});
|
||||
|
||||
it("counts a note edited since the listing was fetched", () => {
|
||||
const tags = ["a", "b", "c", "d", "e", "f"].map((id) => tag(id, "2026-10-02T00:00:00Z"));
|
||||
tags[0].last_used = null;
|
||||
const notes = [{ labels: [{ id: "a" }], updated_at: "2026-10-12T00:00:00+00:00" }];
|
||||
expect(names(recentTags(tags, notes, {}))).toContain("a");
|
||||
});
|
||||
|
||||
it("fills with tags never used, by name, when fewer than five have been", () => {
|
||||
const tags = ["f", "e", "d", "c", "b", "a"].map((id) => tag(id));
|
||||
tags[0].last_used = "2026-10-02T00:00:00Z";
|
||||
expect(names(recentTags(tags, [], {}))).toEqual(["a", "b", "c", "d", "f"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// The rail keeps only the few tags you are using, so a long tag list never pushes
|
||||
// anything out of view (#5931). A tag is as recent as the later of:
|
||||
// - its last use: the newest note carrying it, from the listing (`last_used`), and
|
||||
// from the notes already loaded, so a tag applied a moment ago counts before the
|
||||
// listing is next fetched;
|
||||
// - its last view: opening its lens, remembered on this device for this user.
|
||||
// The few shown are listed by name, not by recency, so a row does not move under
|
||||
// the pointer when it is clicked.
|
||||
|
||||
export const RECENT_TAG_COUNT = 5;
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
interface TaggedNote {
|
||||
labels: { id: string }[];
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export type TagViews = Record<string, number>;
|
||||
|
||||
function ms(when: string | null | undefined): number {
|
||||
const t = when ? Date.parse(when) : NaN;
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
export function recentTags<T extends Tag>(
|
||||
tags: T[],
|
||||
notes: TaggedNote[],
|
||||
views: TagViews,
|
||||
count = RECENT_TAG_COUNT,
|
||||
): T[] {
|
||||
if (tags.length <= count) return tags;
|
||||
const latest = new Map<string, number>();
|
||||
const bump = (id: string, t: number) => latest.set(id, Math.max(latest.get(id) ?? 0, t));
|
||||
for (const tag of tags) {
|
||||
bump(tag.id, ms(tag.last_used));
|
||||
bump(tag.id, views[tag.id] ?? 0);
|
||||
}
|
||||
for (const note of notes) {
|
||||
const t = ms(note.updated_at);
|
||||
for (const lb of note.labels) if (latest.has(lb.id)) bump(lb.id, t);
|
||||
}
|
||||
return [...tags]
|
||||
.sort((a, b) => (latest.get(b.id) ?? 0) - (latest.get(a.id) ?? 0) || a.name.localeCompare(b.name))
|
||||
.slice(0, count)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Views live in this browser, one record per user so a shared machine keeps each
|
||||
// person's own. Storage can be missing or refuse (private windows); the rail then
|
||||
// ranks by use alone.
|
||||
const VIEWS_KEY = "inkwell.tag-views.";
|
||||
|
||||
export function loadTagViews(userId: string): TagViews {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY + userId) ?? "{}");
|
||||
return parsed && typeof parsed === "object" ? (parsed as TagViews) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function recordTagView(userId: string, views: TagViews, tagId: string, at = Date.now()): TagViews {
|
||||
const next = { ...views, [tagId]: at };
|
||||
try {
|
||||
localStorage.setItem(VIEWS_KEY + userId, JSON.stringify(next));
|
||||
} catch {
|
||||
// Kept for this session only.
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export interface Label {
|
||||
color: string;
|
||||
// Number of notes carrying this label (from GET /api/labels; used in label management).
|
||||
count?: number;
|
||||
// The newest edit among the untrashed notes carrying it, listings only. Ranks the
|
||||
// rail's recent tags (#5931).
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
export const useLabelsStore = defineStore("labels", () => {
|
||||
|
||||
Reference in New Issue
Block a user