diff --git a/frontend/src/app/App.vue b/frontend/src/app/App.vue index db3837e..b090a42 100644 --- a/frontend/src/app/App.vue +++ b/frontend/src/app/App.vue @@ -40,6 +40,7 @@
+
@@ -50,6 +51,8 @@ import { ref, computed } from 'vue' import { RouterView, useRouter } from 'vue-router' import { AppSidebar } from '../shared/ui/index' +import AppBreadcrumb from '../shared/breadcrumb/AppBreadcrumb.vue' +import { useBreadcrumbStore } from '../shared/breadcrumb/store' import { useSettingsStore } from '../features/settings/store' import { useDocumentStore } from '../features/document/store' import { useFeatureFlag } from '../features/feature-flags' @@ -58,6 +61,7 @@ import { useI18n } from '../shared/i18n' useSettingsStore() const flagStore = useFeatureFlagStore() +const breadcrumbStore = useBreadcrumbStore() const { t } = useI18n() const router = useRouter() const documentStore = useDocumentStore() diff --git a/frontend/src/pages/DocWorkspacePage.vue b/frontend/src/pages/DocWorkspacePage.vue index 37ea58f..d783852 100644 --- a/frontend/src/pages/DocWorkspacePage.vue +++ b/frontend/src/pages/DocWorkspacePage.vue @@ -8,9 +8,13 @@ diff --git a/frontend/src/shared/breadcrumb/AppBreadcrumb.vue b/frontend/src/shared/breadcrumb/AppBreadcrumb.vue new file mode 100644 index 0000000..e424b55 --- /dev/null +++ b/frontend/src/shared/breadcrumb/AppBreadcrumb.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/shared/breadcrumb/store.test.ts b/frontend/src/shared/breadcrumb/store.test.ts new file mode 100644 index 0000000..b199873 --- /dev/null +++ b/frontend/src/shared/breadcrumb/store.test.ts @@ -0,0 +1,71 @@ +import { createPinia, setActivePinia } from 'pinia' +import { ref, type Ref } from 'vue' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useBreadcrumbStore, useCrumbs } from './store' +import type { Crumb } from './types' + +/** + * The composable side of `useCrumbs` registers `onBeforeUnmount` which + * requires a component lifecycle context. The project does not have + * `@vue/test-utils`, so we test the store API directly. The composable + * lifecycle is exercised end-to-end by the integration tests landing + * in #211 / #216. + * + * We DO test the reactive-input behaviour by calling `useCrumbs` with + * a ref inside a setup-style scope; the watch fires synchronously + * thanks to `immediate: true`. + */ +describe('useBreadcrumbStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('starts empty', () => { + const store = useBreadcrumbStore() + expect(store.crumbs).toEqual([]) + }) + + it('setCrumbs replaces the segments', () => { + const store = useBreadcrumbStore() + const next: Crumb[] = [{ kind: 'leaf', label: 'a' }] + store.setCrumbs(next) + expect(store.crumbs).toEqual(next) + }) + + it('clear empties the segments', () => { + const store = useBreadcrumbStore() + store.setCrumbs([{ kind: 'leaf', label: 'a' }]) + store.clear() + expect(store.crumbs).toEqual([]) + }) +}) + +describe('useCrumbs (reactive source path)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('seeds the store from a ref synchronously', () => { + const store = useBreadcrumbStore() + const dynamic: Ref = ref([{ kind: 'leaf', label: 'first' }]) + // Note: `useCrumbs` calls `onBeforeUnmount` which is a no-op when + // there is no current component — Vue logs a warning but the watch + // path still installs. + useCrumbs(dynamic) + expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'first' }]) + + dynamic.value = [{ kind: 'leaf', label: 'second' }] + // The watch is `immediate: true`; for subsequent updates it runs + // on the next microtask. + return Promise.resolve().then(() => { + expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'second' }]) + }) + }) + + it('seeds the store from a static array', () => { + const store = useBreadcrumbStore() + useCrumbs([{ kind: 'leaf', label: 'static' }]) + expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'static' }]) + }) +}) diff --git a/frontend/src/shared/breadcrumb/store.ts b/frontend/src/shared/breadcrumb/store.ts new file mode 100644 index 0000000..6cd7ace --- /dev/null +++ b/frontend/src/shared/breadcrumb/store.ts @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia' +import { onBeforeUnmount, watch, type Ref, type ComputedRef } from 'vue' + +import type { Crumb } from './types' + +/** + * Store the current breadcrumb segments. Pages set them on mount via + * `useCrumbs(crumbs)` and clear them on unmount automatically. + * + * The shell (`App.vue`) reads `crumbs` and renders `` — + * empty array → component renders nothing. + */ +export const useBreadcrumbStore = defineStore('breadcrumb', { + state: () => ({ + crumbs: [] as Crumb[], + }), + actions: { + setCrumbs(crumbs: Crumb[]) { + this.crumbs = crumbs + }, + clear() { + this.crumbs = [] + }, + }, +}) + +/** + * Composable for pages to declare their breadcrumb. Auto-clears on + * unmount so a stale breadcrumb never leaks to the next route. + * + * Accepts a static array OR a reactive ref / computed so pages with + * async data (e.g. doc workspace fetching the doc title) can rebuild + * crumbs as the data lands. + */ +export function useCrumbs(source: Crumb[] | Ref | ComputedRef) { + const store = useBreadcrumbStore() + + if (Array.isArray(source)) { + store.setCrumbs(source) + } else { + watch( + source, + (next) => { + store.setCrumbs(next) + }, + { immediate: true }, + ) + } + + onBeforeUnmount(() => { + store.clear() + }) +} diff --git a/frontend/src/shared/breadcrumb/text.test.ts b/frontend/src/shared/breadcrumb/text.test.ts new file mode 100644 index 0000000..9639e57 --- /dev/null +++ b/frontend/src/shared/breadcrumb/text.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { truncate } from './text' + +describe('truncate', () => { + it('returns the input unchanged when shorter than the limit', () => { + expect(truncate('short', 10)).toBe('short') + expect(truncate('exactly10!', 10)).toBe('exactly10!') + }) + + it('truncates and adds an ellipsis when longer than the limit', () => { + expect(truncate('this is a long title', 10)).toBe('this is a…') + }) + + it('trims trailing whitespace before the ellipsis', () => { + expect(truncate('foo bar baz qux', 8)).toBe('foo bar…') + }) + + it('returns the input untouched for non-positive limits', () => { + expect(truncate('hello', 0)).toBe('hello') + expect(truncate('hello', -5)).toBe('hello') + }) + + it('handles empty strings', () => { + expect(truncate('', 10)).toBe('') + }) +}) diff --git a/frontend/src/shared/breadcrumb/text.ts b/frontend/src/shared/breadcrumb/text.ts new file mode 100644 index 0000000..b8f6fd6 --- /dev/null +++ b/frontend/src/shared/breadcrumb/text.ts @@ -0,0 +1,14 @@ +/** + * Truncate `text` to at most `max` characters, replacing the tail with + * an ellipsis when shortened. Returns the original string if it + * already fits. + * + * Used by the breadcrumb to keep the topbar tidy when document + * filenames are long. The full title stays available via the `title` + * attribute on the segment so the user can hover to read it whole. + */ +export function truncate(text: string, max: number): string { + if (max <= 0 || text.length <= max) return text + // Reserve one character for the ellipsis so the visible length is `max`. + return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…' +} diff --git a/frontend/src/shared/breadcrumb/types.ts b/frontend/src/shared/breadcrumb/types.ts new file mode 100644 index 0000000..7707c92 --- /dev/null +++ b/frontend/src/shared/breadcrumb/types.ts @@ -0,0 +1,13 @@ +import type { RouteLocationRaw } from 'vue-router' + +/** + * One segment in a breadcrumb. + * + * - `LinkCrumb` renders as a `` to `to`. + * - `LeafCrumb` renders as a non-clickable label with `aria-current="page"`. + * The leaf is always the last segment; the rendering component refuses + * to render a leaf in a non-final position. + */ +export type LinkCrumb = { kind: 'link'; label: string; to: RouteLocationRaw } +export type LeafCrumb = { kind: 'leaf'; label: string } +export type Crumb = LinkCrumb | LeafCrumb diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 3b74247..63db0fd 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -19,6 +19,13 @@ const messages: Messages = { // Top bar 'topbar.newAnalysis': 'Nouvelle analyse', + // Breadcrumb (0.6.0 doc workspace — #208) + 'breadcrumb.aria': "Fil d'Ariane", + 'breadcrumb.studio': 'Studio', + 'breadcrumb.mode.ask': 'Ask', + 'breadcrumb.mode.inspect': 'Inspect', + 'breadcrumb.mode.chunks': 'Chunks', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Bientôt disponible', 'comingSoon.subtitle.docsLibrary': @@ -307,6 +314,13 @@ const messages: Messages = { 'topbar.newAnalysis': 'New analysis', + // Breadcrumb (0.6.0 doc workspace — #208) + 'breadcrumb.aria': 'Breadcrumb', + 'breadcrumb.studio': 'Studio', + 'breadcrumb.mode.ask': 'Ask', + 'breadcrumb.mode.inspect': 'Inspect', + 'breadcrumb.mode.chunks': 'Chunks', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Coming soon', 'comingSoon.subtitle.docsLibrary':