Merge pull request #233 from scub-france/feature/208-doc-workspace-breadcrumb

feat(#208): doc workspace breadcrumb (Studio > <doc> > <mode>)
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:53:13 +02:00 committed by GitHub
commit a645d26e6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 308 additions and 1 deletions

View file

@ -40,6 +40,7 @@
<div class="app-body">
<AppSidebar :open="sidebarOpen" />
<main class="main">
<AppBreadcrumb :crumbs="breadcrumbStore.crumbs" />
<RouterView />
</main>
</div>
@ -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()

View file

@ -8,9 +8,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import { useCrumbs } from '../shared/breadcrumb/store'
import { truncate } from '../shared/breadcrumb/text'
import type { Crumb } from '../shared/breadcrumb/types'
import { useI18n } from '../shared/i18n'
import { type DocMode } from '../shared/routing/modes'
import { ROUTES } from '../shared/routing/names'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
/**
@ -24,4 +28,18 @@ const props = defineProps<{ id: string; mode: DocMode }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
// Provide the breadcrumb segments. Once the doc is fetched (E4 / #216),
// `truncate(doc.filename, 40)` will replace the id; for now we render
// the id itself so the user can still see what they are looking at.
const crumbs = computed<Crumb[]>(() => [
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
{
kind: 'link',
label: truncate(props.id, 40),
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
},
{ kind: 'leaf', label: t(`breadcrumb.mode.${props.mode}`) },
])
useCrumbs(crumbs)
</script>

View file

@ -0,0 +1,93 @@
<template>
<nav v-if="crumbs.length > 0" class="breadcrumb" :aria-label="t('breadcrumb.aria')">
<ol class="breadcrumb__list">
<li
v-for="(crumb, index) in crumbs"
:key="`${index}-${crumb.label}`"
class="breadcrumb__item"
>
<RouterLink
v-if="crumb.kind === 'link'"
:to="crumb.to"
class="breadcrumb__link"
:title="crumb.label"
>
{{ crumb.label }}
</RouterLink>
<span v-else class="breadcrumb__leaf" aria-current="page" :title="crumb.label">
{{ crumb.label }}
</span>
<span v-if="index < crumbs.length - 1" class="breadcrumb__sep" aria-hidden="true"> </span>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
import type { Crumb } from './types'
defineProps<{ crumbs: Crumb[] }>()
const { t } = useI18n()
</script>
<style scoped>
.breadcrumb {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 1rem;
font-size: 0.875rem;
color: var(--color-text-muted, #9ca3af);
border-bottom: 1px solid var(--color-border, #1a1a1d);
background: var(--color-surface, #111113);
}
.breadcrumb__list {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
}
.breadcrumb__item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.breadcrumb__link {
color: var(--color-text-muted, #9ca3af);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
transition: color 0.15s;
}
.breadcrumb__link:hover {
color: var(--color-text, #f3f4f6);
text-decoration: underline;
}
.breadcrumb__leaf {
color: var(--color-text, #f3f4f6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
font-weight: 500;
}
.breadcrumb__sep {
color: var(--color-text-muted, #6b7280);
user-select: none;
}
</style>

View file

@ -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<Crumb[]> = 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' }])
})
})

View file

@ -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 `<AppBreadcrumb>`
* 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<Crumb[]> | ComputedRef<Crumb[]>) {
const store = useBreadcrumbStore()
if (Array.isArray(source)) {
store.setCrumbs(source)
} else {
watch(
source,
(next) => {
store.setCrumbs(next)
},
{ immediate: true },
)
}
onBeforeUnmount(() => {
store.clear()
})
}

View file

@ -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('')
})
})

View file

@ -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() + '…'
}

View file

@ -0,0 +1,13 @@
import type { RouteLocationRaw } from 'vue-router'
/**
* One segment in a breadcrumb.
*
* - `LinkCrumb` renders as a `<RouterLink>` 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

View file

@ -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':