feat(web/app): add the workspace, invite and not-found pages
Assemble the workspace shell over the domains, keep sidebar rooms subscribed in the background so messages from other rooms can notify, and port the invite acceptance and 404 pages.
ZhenYi committed
Added view/app/src/pages/invite/ui/space-invite-page.tsx view/app/src/pages/invite/ui/space-invite-page.tsx +164 −0 View view/app/src/pages/invite/ui/space-invite-page.tsx
@@ -0,0 +1,164 @@
1import { Alert } from '@app/components'
2import { AuthCard, AuthShell, fetchSessionUser, IconBadge } from '@app/domains/session'
3import { acceptSpaceInvite } from '@app/domains/space/management'
4import { useT } from '@app/i18n'
5import { ApiFailure, meKey, useAsync } from '@app/lib'
6import { Button } from '@app/ui'
7import { ArrowRight, Loader2, MailQuestionMark, TriangleAlert } from 'lucide-react'
8import { useEffect, useRef, useState } from 'react'
9import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
10
11type Phase = 'joining' | 'signin' | 'failed' | 'invalid'
12type Failure = { code?: number; message?: string }
13
14/** Codes that mean the link itself is spent — retrying cannot change the answer. */
15const SPENT = new Set([403, 404, 409])
16
17/**
18 * Capability link from the invite email:
19 * `{web_base_url}/spaces/invites/accept?code=…&space=…`.
20 *
21 * A signed-in visitor accepts straight away and lands inside the space they
22 * just joined (the `space` hint); an anonymous one is asked to sign in first —
23 * the code stays in the URL, so the same link keeps working after logging in.
24 */
25export function SpaceInvitePage() {
26 const { t } = useT()
27 const navigate = useNavigate()
28 const location = useLocation()
29 const [params] = useSearchParams()
30 const code = params.get('code') ?? ''
31 const space = params.get('space') ?? ''
32 const [tick, setTick] = useState(0)
33 const me = useAsync(fetchSessionUser, [tick], { cacheKey: meKey() })
34 const [acceptFailure, setAcceptFailure] = useState<Failure | null>(null)
35 const started = useRef(-1)
36
37 // A refused session (401/403) outranks a cached identity: the visitor has to
38 // sign in as the account the invitation was addressed to.
39 const refused = me.error instanceof ApiFailure && (me.error.code === 401 || me.error.code === 403)
40 const probeFailure = !me.loading && !me.data && !refused && Boolean(me.error)
41
42 useEffect(() => {
43 // `started` keeps the accept single-shot per attempt: the effect re-runs as
44 // caches refresh, and a consumed code must never be posted twice — only the
45 // retry button bumps `tick` and unlocks another post.
46 if (!code || me.loading || !me.data || started.current === tick) return
47 started.current = tick
48 acceptSpaceInvite(code).then(
49 () => navigate(space ? `/app/${encodeURIComponent(space)}` : '/app', { replace: true }),
50 (err: unknown) => {
51 setAcceptFailure(
52 err instanceof ApiFailure
53 ? { code: err.code, message: err.message }
54 : { message: err instanceof Error ? err.message : undefined },
55 )
56 },
57 )
58 }, [tick, code, space, me.loading, me.data, navigate])
59
60 const failure: Failure | null =
61 acceptFailure ?? (probeFailure ? { message: (me.error as Error | undefined)?.message } : null)
62 const phase: Phase = !code ? 'invalid' : failure ? 'failed' : me.data ? 'joining' : me.loading ? 'joining' : 'signin'
63
64 const failureText =
65 failure?.code === 404
66 ? t('space.invite.accept.notFound')
67 : failure?.code === 403
68 ? t('space.invite.accept.mismatch')
69 : failure?.code === 409
70 ? t('space.invite.accept.used')
71 : failure?.message || t('space.invite.accept.failed')
72
73 const badge =
74 phase === 'signin' ? (
75 <IconBadge>
76 <MailQuestionMark size={20} aria-hidden />
77 </IconBadge>
78 ) : phase === 'joining' ? (
79 <IconBadge>
80 <Loader2 size={20} className="animate-spin" aria-hidden />
81 </IconBadge>
82 ) : (
83 <IconBadge>
84 <TriangleAlert size={20} aria-hidden />
85 </IconBadge>
86 )
87
88 return (
89 <AuthShell
90 current={t('space.invite.accept.crumb').toUpperCase()}
91 hint={t('space.invite.accept.hint').toUpperCase()}
92 >
93 <AuthCard
94 badge={badge}
95 heading={space ? t('space.invite.accept.headingSpace', { space }) : t('space.invite.accept.heading')}
96 subheading={t('space.invite.accept.sub')}
97 >
98 {phase === 'joining' ? (
99 <p className="flex items-center justify-center gap-2 text-[13px] text-muted-foreground" role="status">
100 <Loader2 size={15} className="animate-spin" aria-hidden />
101 {t('space.invite.accept.joining')}
102 </p>
103 ) : null}
104
105 {phase === 'signin' ? (
106 <div className="flex w-full flex-col gap-3">
107 <Alert tone="info">{t('space.invite.accept.signInNote')}</Alert>
108 <Button
109 size="lg"
110 className="w-full"
111 onClick={() =>
112 navigate(`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`)
113 }
114 >
115 {t('space.invite.accept.login')}
116 <ArrowRight size={15} aria-hidden />
117 </Button>
118 <Button
119 variant="secondary"
120 size="lg"
121 className="w-full"
122 onClick={() =>
123 navigate(`/auth/signup?redirect=${encodeURIComponent(location.pathname + location.search)}`)
124 }
125 >
126 {t('space.invite.accept.signup')}
127 </Button>
128 </div>
129 ) : null}
130
131 {phase === 'failed' ? (
132 <div className="flex w-full flex-col gap-3">
133 <Alert tone="error">{failureText}</Alert>
134 <div className="flex w-full gap-2">
135 {SPENT.has(failure?.code ?? 0) ? null : (
136 <Button
137 className="flex-1"
138 onClick={() => {
139 setAcceptFailure(null)
140 setTick((value) => value + 1)
141 }}
142 >
143 {t('space.invite.accept.retry')}
144 </Button>
145 )}
146 <Button variant="secondary" className="flex-1" onClick={() => navigate('/app')}>
147 {t('space.invite.accept.home')}
148 </Button>
149 </div>
150 </div>
151 ) : null}
152
153 {phase === 'invalid' ? (
154 <div className="flex w-full flex-col gap-3">
155 <Alert tone="error">{t('space.invite.accept.invalid')}</Alert>
156 <Button variant="secondary" size="lg" className="w-full" onClick={() => navigate('/app')}>
157 {t('space.invite.accept.home')}
158 </Button>
159 </div>
160 ) : null}
161 </AuthCard>
162 </AuthShell>
163 )
164}
Added view/app/src/pages/invite/index.ts view/app/src/pages/invite/index.ts +1 −0 View view/app/src/pages/invite/index.ts
@@ -0,0 +1 @@
1export { SpaceInvitePage } from './ui/space-invite-page'
Added view/app/src/pages/not-found/ui/not-found-page.tsx view/app/src/pages/not-found/ui/not-found-page.tsx +30 −0 View view/app/src/pages/not-found/ui/not-found-page.tsx
@@ -0,0 +1,30 @@
1import { useT } from '@app/i18n'
2import { Button } from '@app/ui'
3import { useEffect } from 'react'
4import { Link } from 'react-router-dom'
5
6/**
7 * Fallback screen for unmatched routes.
8 * It is a route-level composition, so it is a page slice rather than app UI.
9 */
10export function NotFoundPage() {
11 const { t } = useT()
12
13 // A 404 can be the first paint of a fresh tab, so give it a real title.
14 useEffect(() => {
15 document.title = `${t('error.notFound.title')} · ${t('common.app.name')}`
16 }, [t])
17
18 return (
19 <main className="grid min-h-screen place-content-center gap-4 p-8 text-center">
20 <p className="text-7xl font-semibold tracking-tight text-slate-300">404</p>
21 <h1 className="text-xl">{t('error.notFound.title')}</h1>
22 <p className="max-w-sm text-sm text-slate-500">{t('error.notFound.body')}</p>
23 <div className="justify-self-center">
24 <Link to="/app/@me">
25 <Button>{t('error.notFound.back')}</Button>
26 </Link>
27 </div>
28 </main>
29 )
30}
Added view/app/src/pages/not-found/index.ts view/app/src/pages/not-found/index.ts +1 −0 View view/app/src/pages/not-found/index.ts
@@ -0,0 +1 @@
1export { NotFoundPage } from './ui/not-found-page'
Added view/app/src/pages/workspace/model/board-location.ts view/app/src/pages/workspace/model/board-location.ts +229 −0 View view/app/src/pages/workspace/model/board-location.ts
@@ -0,0 +1,229 @@
1import type { IssueBoardView, IssuePriority, IssueSort, IssueState } from '@app/domains/issues/model'
2import { PRIORITIES } from '@app/domains/issues/model'
3import { useCallback, useMemo } from 'react'
4import { useSearchParams } from 'react-router-dom'
5
6/**
7 * The issue-board (问题集) page state, encoded entirely in the route's query
8 * string, mirroring `repo-location` for the repository pages:
9 *
10 * ```text
11 * /app/demo/board/01a0… → 看板视图 · 未关闭 · 最新创建
12 * /app/demo/board/01a0…?view=list → 列表视图
13 * /app/demo/board/01a0…?state=all&sort=updated&q=登录
14 * /app/demo/board/01a0…?label=01a0…&priority=high&assignee=01a0…
15 * /app/demo/board/01a0…?page=3 → 第 3 页(50 条/页)
16 * /app/demo/board/01a0…?issue=42 → 工单 #42 详情(含其评论与活动)
17 * ```
18 *
19 * Every transition is a react-router search-param update, so the board never
20 * pays a document reload; the browser's back/forward buttons walk the filters.
21 *
22 * Not in the URL: which modal is open, the composer draft, the in-progress
23 * drag — ephemeral interaction state that would only spam the history stack.
24 *
25 * 视图 (board/list) is the one field with a *user* default: it comes from the
26 * saved preference (`board_view_mode`), so a plain board URL renders whatever
27 * layout this account prefers. An explicit `?view=` always wins and is what a
28 * shared link carries.
29 */
30/** Defaults are omitted from the URL, so the plain board reads as `/app/…/board/<id>`. */
31export const BOARD_VIEW_DEFAULTS: IssueBoardView = {
32 view: 'board',
33 state: 'open',
34 q: '',
35 priorities: [],
36 labelIds: [],
37 milestoneIds: [],
38 assigneeIds: [],
39 sort: 'created',
40 page: 1,
41 issue: '',
42}
43
44/** Changing any of these resets paging to page 1. */
45const FILTER_KEYS = ['view', 'state', 'q', 'priorities', 'labelIds', 'milestoneIds', 'assigneeIds', 'sort'] as const
46export type BoardFilterKey = (typeof FILTER_KEYS)[number]
47
48const VIEWS = ['board', 'list'] as const
49const STATES: readonly (IssueState | 'all')[] = ['open', 'closed', 'all']
50const PRIORITY_IDS: readonly IssuePriority[] = PRIORITIES.map((meta) => meta.id)
51const SORTS: readonly IssueSort[] = ['created', 'updated', 'due', 'priority', 'number']
52
53const OWNED_KEYS = ['view', 'state', 'q', 'priority', 'label', 'milestone', 'assignee', 'sort', 'page', 'issue']
54
55function pick<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
56 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
57}
58
59/** `a,b` → `['a','b']`, dropping blanks; `allowed` (when given) drops junk. */
60function readList(raw: string | null, allowed?: readonly string[]): string[] {
61 if (!raw) return []
62 const parts = raw
63 .split(',')
64 .map((part) => part.trim())
65 .filter(Boolean)
66 return allowed ? parts.filter((part) => allowed.includes(part)) : parts
67}
68
69/** The inverse of {@link readList}; encoded as one comma-separated value. */
70function writeList(next: URLSearchParams, key: string, values: readonly string[]) {
71 if (values.length > 0) next.set(key, values.join(','))
72}
73
74/**
75 * Parse the URL into board state; unknown values fall back to their default.
76 * `defaultView` is the account's saved 问题集 view, used only while the URL
77 * does not name one itself.
78 */
79export function readIssueBoardView(
80 params: URLSearchParams,
81 defaultView: IssueBoardView['view'] = 'board',
82): IssueBoardView {
83 const rawPage = Number.parseInt(params.get('page') ?? '', 10)
84 return {
85 view: pick(params.get('view'), VIEWS, defaultView),
86 state: pick(params.get('state'), STATES, 'open'),
87 q: params.get('q') ?? '',
88 priorities: readList(params.get('priority'), PRIORITY_IDS) as IssuePriority[],
89 labelIds: readList(params.get('label')),
90 milestoneIds: readList(params.get('milestone')),
91 assigneeIds: readList(params.get('assignee')),
92 sort: pick(params.get('sort'), SORTS, 'created'),
93 page: Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1,
94 issue: params.get('issue') ?? '',
95 }
96}
97
98/** Encode state, dropping every default-valued key so plain URLs stay short. */
99export function writeIssueBoardView(
100 base: URLSearchParams,
101 state: IssueBoardView,
102 defaultView: IssueBoardView['view'] = 'board',
103): URLSearchParams {
104 const next = new URLSearchParams(base)
105 for (const key of OWNED_KEYS) next.delete(key)
106 if (state.view !== defaultView) next.set('view', state.view)
107 if (state.state !== 'open') next.set('state', state.state)
108 if (state.q) next.set('q', state.q)
109 writeList(next, 'priority', state.priorities)
110 writeList(next, 'label', state.labelIds)
111 writeList(next, 'milestone', state.milestoneIds)
112 writeList(next, 'assignee', state.assigneeIds)
113 if (state.sort !== 'created') next.set('sort', state.sort)
114 if (state.page > 1) next.set('page', String(state.page))
115 if (state.issue) next.set('issue', state.issue)
116 return next
117}
118
119/**
120 * Merge a partial update. A filter change always returns to page 1 — page 3 of
121 * a result set that just changed shape is a different question, and paging
122 * past the end would read as "no results".
123 */
124export function applyBoardViewPatch(current: IssueBoardView, updates: Partial<IssueBoardView>): IssueBoardView {
125 const merged = { ...current, ...updates }
126 const filterChanged = FILTER_KEYS.some((key) => updates[key] !== undefined && updates[key] !== current[key])
127 return filterChanged && merged.page === current.page ? { ...merged, page: 1 } : merged
128}
129
130export interface BoardViewLocation {
131 view: IssueBoardView
132 /** The account's saved 问题集 view — what a URL without `?view=` renders. */
133 defaultView: IssueBoardView['view']
134 /** True when the URL itself pins the view, so no preference is consulted. */
135 pinnedView: boolean
136 /** True while at least one filter narrows the result set. */
137 filtered: boolean
138 patch: (updates: Partial<IssueBoardView>, options?: { replace?: boolean }) => void
139 setView: (view: IssueBoardView['view']) => void
140 setFilter: (updates: Partial<IssueBoardView>) => void
141 setPage: (page: number) => void
142 clearFilters: () => void
143 openIssue: (issue: string) => void
144 closeIssue: () => void
145 /** Drop every param when the board under the same route changes. */
146 normalizeForBoard: () => void
147}
148
149/**
150 * Board view state as URL state (no component-local mirrors).
151 *
152 * `defaultView` is the saved preference, applied to any board URL that does not
153 * carry an explicit `?view=`. It is *not* synced into the URL: staying out of
154 * the URL is what keeps "follow my preference" distinct from "this link says
155 * list", and it means changing the preference re-lays out an already-open
156 * board.
157 */
158export function useBoardViewLocation(defaultView: IssueBoardView['view'] = 'board'): BoardViewLocation {
159 const [params, setParams] = useSearchParams()
160 const view = useMemo(() => readIssueBoardView(params, defaultView), [params, defaultView])
161
162 const patch = useCallback<BoardViewLocation['patch']>(
163 (updates, options) => {
164 setParams(
165 (prev) =>
166 writeIssueBoardView(prev, applyBoardViewPatch(readIssueBoardView(prev, defaultView), updates), defaultView),
167 {
168 replace: options?.replace ?? false,
169 preventScrollReset: true,
170 },
171 )
172 },
173 [setParams, defaultView],
174 )
175
176 const setFilter = useCallback<BoardViewLocation['setFilter']>((updates) => patch(updates), [patch])
177 const setView = useCallback<BoardViewLocation['setView']>((view) => patch({ view }), [patch])
178 const setPage = useCallback<BoardViewLocation['setPage']>((page) => patch({ page }), [patch])
179 const openIssue = useCallback<BoardViewLocation['openIssue']>((issue) => patch({ issue }), [patch])
180 const closeIssue = useCallback(() => patch({ issue: '' }), [patch])
181 const clearFilters = useCallback(
182 () =>
183 patch({
184 state: 'open',
185 q: '',
186 priorities: [],
187 labelIds: [],
188 milestoneIds: [],
189 assigneeIds: [],
190 sort: 'created',
191 }),
192 [patch],
193 )
194 const normalizeForBoard = useCallback<BoardViewLocation['normalizeForBoard']>(
195 () =>
196 setParams(
197 (prev) =>
198 // A fresh board starts at the account's own view: normalizing back to
199 // a literal `board` would pin the next board against the preference.
200 writeIssueBoardView(prev, { ...BOARD_VIEW_DEFAULTS, view: defaultView }, defaultView),
201 { replace: true, preventScrollReset: true },
202 ),
203 [setParams, defaultView],
204 )
205
206 const filtered =
207 view.state !== 'open' ||
208 Boolean(view.q) ||
209 view.priorities.length > 0 ||
210 view.labelIds.length > 0 ||
211 view.milestoneIds.length > 0 ||
212 view.assigneeIds.length > 0 ||
213 view.sort !== 'created'
214
215 return {
216 view,
217 defaultView,
218 pinnedView: VIEWS.includes((params.get('view') ?? '') as IssueBoardView['view']),
219 filtered,
220 patch,
221 setView,
222 setFilter,
223 setPage,
224 clearFilters,
225 openIssue,
226 closeIssue,
227 normalizeForBoard,
228 }
229}
Added view/app/src/pages/workspace/model/chat-location.ts view/app/src/pages/workspace/model/chat-location.ts +89 −0 View view/app/src/pages/workspace/model/chat-location.ts
@@ -0,0 +1,89 @@
1/**
2 * The 聊天室 page state, encoded entirely in the route's query string, mirroring
3 * `repo-location` and `board-location`:
4 *
5 * ```text
6 * /app/demo/chat/01a0… → 频道主体
7 * /app/demo/chat/01a0…?panel=members → 成员面板
8 * /app/demo/chat/01a0…?panel=thread&thread=01a0… → 某个话题
9 * /app/demo/chat/01a0…?panel=search&q=发布&scope=space → 全站搜索结果
10 * /app/demo/chat/01a0…?jump=01a0… → 打开并定位到一条消息
11 * /app/demo/chat/01a0…?reply=01a0… → 引用回复该消息
12 * ```
13 *
14 * `reply` / `edit` / `jump` are *one-shot*: the chat view applies them, then
15 * replaces the URL so a refresh does not re-open an editor. Every transition is a
16 * react-router search-param update, so the room never pays a document reload and
17 * the browser's back button walks the panels.
18 *
19 * Not in the URL: the draft, scroll position, in-progress upload, which popover
20 * is open — interaction state that would only spam the history stack.
21 */
22
23import type { ChatPanelKind, ChatViewState } from '@app/domains/chat'
24import { useCallback, useMemo } from 'react'
25import { useSearchParams } from 'react-router-dom'
26
27const PANELS: readonly ChatPanelKind[] = ['thread', 'pins', 'threads', 'members', null]
28
29/** Params this page owns; anything else on the URL belongs to another view. */
30const OWNED_KEYS = ['panel', 'thread', 'reply', 'edit', 'jump']
31
32function readPanel(raw: string | null): ChatPanelKind {
33 return (PANELS as readonly (string | null)[]).includes(raw) ? (raw as ChatPanelKind) : null
34}
35
36/** Parse the URL into view state; unknown panel values fall back to "no panel". */
37export function readChatView(params: URLSearchParams): ChatViewState {
38 return {
39 panel: readPanel(params.get('panel')),
40 threadId: params.get('thread') ?? undefined,
41 replyToId: params.get('reply') ?? undefined,
42 editId: params.get('edit') ?? undefined,
43 jumpId: params.get('jump') ?? undefined,
44 }
45}
46
47/** Encode state, dropping defaults so a plain room keeps a short URL. */
48export function writeChatView(base: URLSearchParams, state: ChatViewState): URLSearchParams {
49 const next = new URLSearchParams(base)
50 for (const key of OWNED_KEYS) next.delete(key)
51 if (state.panel) next.set('panel', state.panel)
52 // A thread id without an open thread panel is meaningless: never persist it.
53 if (state.panel === 'thread' && state.threadId) next.set('thread', state.threadId)
54 if (state.replyToId) next.set('reply', state.replyToId)
55 if (state.editId) next.set('edit', state.editId)
56 if (state.jumpId) next.set('jump', state.jumpId)
57 return next
58}
59
60export interface ChatViewLocation {
61 view: ChatViewState
62 /** Merge a patch into the URL; defaults are dropped, foreign keys survive. */
63 patch: (updates: Partial<ChatViewState>, options?: { replace?: boolean }) => void
64}
65
66/**
67 * Chat view state as URL state. There is deliberately one mutator: the panel
68 * rules (a `thread` only exists with `panel=thread`, a `q` only with
69 * `panel=search`) live in {@link writeChatView}, so every caller gets them for
70 * free and no per-transition helper can forget them.
71 */
72export function useChatViewLocation(): ChatViewLocation {
73 const [params, setParams] = useSearchParams()
74 const view = useMemo(() => readChatView(params), [params])
75
76 const patch = useCallback<ChatViewLocation['patch']>(
77 (updates, options) => {
78 setParams((previous) => writeChatView(previous, { ...readChatView(previous), ...updates }), {
79 // One-shot targets are *replaced*: leaving a back-button stop for every
80 // row the reader clicked would make the history useless.
81 replace: options?.replace ?? false,
82 preventScrollReset: true,
83 })
84 },
85 [setParams],
86 )
87
88 return { view, patch }
89}
Added view/app/src/pages/workspace/model/initial.ts view/app/src/pages/workspace/model/initial.ts +4 −0 View view/app/src/pages/workspace/model/initial.ts
@@ -0,0 +1,4 @@
1/** Fallback avatar glyph for an account or space with no picture. */
2export function initial(name: string): string {
3 return name.slice(0, 1).toUpperCase()
4}
Added view/app/src/pages/workspace/model/nav-order.ts view/app/src/pages/workspace/model/nav-order.ts +17 −0 View view/app/src/pages/workspace/model/nav-order.ts
@@ -0,0 +1,17 @@
1/**
2 * Position arithmetic for the sidebar's drag-and-drop.
3 *
4 * Both move endpoints take an absolute `position`, so a drop has to invent one
5 * that lands between two neighbours. Halving the gap keeps a single write per
6 * move; when the gap is a single integer there is no room left and the caller
7 * renumbers that level with {@link STRIDE} instead.
8 */
9export const STRIDE = 1024
10
11/** Midpoint of two positions, or `null` when the gap cannot be split further. */
12export function betweenPositions(before: number | null, after: number | null): number | null {
13 if (before === null && after === null) return STRIDE
14 if (before === null) return after !== null && after > 2 ? Math.floor(after / 2) : null
15 if (after === null) return before + STRIDE
16 return after - before >= 2 ? Math.floor((before + after) / 2) : null
17}
Added view/app/src/pages/workspace/model/prefetch.ts view/app/src/pages/workspace/model/prefetch.ts +75 −0 View view/app/src/pages/workspace/model/prefetch.ts
@@ -0,0 +1,75 @@
1/**
2 * Prefetch composition for the workspace page: the bootstrap warm-up, the
3 * per-space list bundle behind the rail, and re-exports of the column bundles
4 * the sidebar rows dispatch to on hover. The bundles must mirror the
5 * mount-time `useAsync` calls (same fetcher, same cache key), so a click
6 * during a prefetch joins the in-flight request instead of repeating it.
7 */
8
9import { prefetchRoomView } from '@app/domains/chat'
10import type { RoomBrief } from '@app/domains/chat/model'
11import { prefetchBoardView } from '@app/domains/issues'
12import { prefetchRepoView } from '@app/domains/repo/browse'
13import { fetchSpaceDir } from '@app/domains/space/management'
14import { dmsKey, meKey, prefetch, spaceBoardsKey, spaceDirKey, spaceReposKey, spaceRoomsKey, spacesKey } from '@app/lib'
15import { fetchDms, fetchMe, fetchMySpaces, fetchSpaceBoards, fetchSpaceRepos, fetchSpaceRooms } from './shell-api'
16import type { BoardItem, RepoItem } from './workspace'
17
18export { prefetchBoardView, prefetchRepoView, prefetchRoomView }
19
20/**
21 * Warm the shell's identity and global lists as early as the app boots, in
22 * parallel with the router mount. Skipped outside `/app` so the auth screens
23 * never trigger authenticated calls (and their 401s never feed the outage
24 * gate).
25 */
26export function prefetchWorkspaceBootstrap(): void {
27 if (!window.location.pathname.startsWith('/app')) return
28 void prefetch(meKey(), fetchMe)
29 void prefetch(spacesKey(), fetchMySpaces)
30 void prefetch(dmsKey(), fetchDms)
31}
32
33/** Rail hover: the five sidebar lists of the space about to be opened. */
34export function prefetchSpaceLists(spaceName: string): void {
35 if (!spaceName || spaceName === '@me') return
36 void prefetch(spaceRoomsKey(spaceName), () => fetchSpaceRooms(spaceName))
37 void prefetch(spaceDirKey(spaceName), () => fetchSpaceDir(spaceName))
38 void prefetch(spaceReposKey(spaceName), () => fetchSpaceRepos(spaceName))
39 void prefetch(spaceBoardsKey(spaceName), () => fetchSpaceBoards(spaceName))
40}
41
42/** How many rooms the idle warm-up may prefetch per space. */
43const IDLE_ROOM_COUNT = 3
44
45function onIdle(run: () => void): void {
46 if ('requestIdleCallback' in window) {
47 window.requestIdleCallback(run, { timeout: 4000 })
48 return
49 }
50 // Safari has no requestIdleCallback; a plain delay keeps it off first paint.
51 setTimeout(run, 1500)
52}
53
54/**
55 * Idle top-N warm-up for one space: the rooms most likely to be opened next
56 * (unread first, then favorites — the same ranking the eye follows in the
57 * sidebar), plus the first repo and board rows. Runs off the critical path,
58 * after the lists themselves have painted.
59 */
60export function prefetchLikelyTargets(
61 spaceName: string,
62 rooms: RoomBrief[],
63 repos: RepoItem[],
64 boards: BoardItem[],
65): void {
66 onIdle(() => {
67 const ranked = rooms
68 .filter((room) => room.kind !== 'Category' && room.kind !== 'Thread' && room.kind !== 'Voice' && !room.hidden)
69 .sort((a, b) => (b.unread_count ?? 0) - (a.unread_count ?? 0) || Number(b.favorite) - Number(a.favorite))
70 .slice(0, IDLE_ROOM_COUNT)
71 for (const room of ranked) prefetchRoomView(room.id)
72 if (repos[0]) void prefetchRepoView(spaceName, repos[0].name)
73 if (boards[0]) prefetchBoardView(spaceName, boards[0].id)
74 })
75}
Added view/app/src/pages/workspace/model/repo-location.ts view/app/src/pages/workspace/model/repo-location.ts +309 −0 View view/app/src/pages/workspace/model/repo-location.ts
@@ -0,0 +1,309 @@
1import type { PullSubTab } from '@app/domains/pulls'
2import type { DiffStyle } from '@app/domains/repo/model'
3// The settings feature owns its section ids (the page sits above features, so
4// importing them is legal); the URL just has to speak the same alphabet.
5import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '@app/domains/repo/settings'
6import { useCallback, useMemo } from 'react'
7import { useSearchParams } from 'react-router-dom'
8import type { RepoTabId } from './route'
9
10/**
11 * The repository page's view state, encoded entirely in the route's query
12 * string, so every screen the repo page can reach is addressable, restorable
13 * by the browser's back/forward buttons and shareable:
14 *
15 * ```text
16 * /app/demo/repo/demorepo → 代码 · 默认分支 · 根目录
17 * /app/demo/repo/demorepo?rev=main&path=.github/workflows → 代码 · 子目录
18 * /app/demo/repo/demorepo?rev=main&file=README.md → 代码 · 单文件
19 * /app/demo/repo/demorepo?tab=commits&rev=main → 提交列表
20 * /app/demo/repo/demorepo?tab=commits&commit=99eea8e → 单个提交的 diff
21 * /app/demo/repo/demorepo?tab=commits&path=README.md&fp=1 → 该路径的历史(仅首父)
22 * /app/demo/repo/demorepo?tab=commits&commit=99eea8e&diff=split → 分栏 diff
23 * /app/demo/repo/demorepo?tab=settings§ion=locks → 设置的二级页
24 * ```
25 *
26 * Every transition is a `react-router` search-param update, so nothing here
27 * ever touches `window.location` — the app never pays a document reload.
28 *
29 * Deliberately *not* in the URL: ephemeral widget state (open dropdowns, filter
30 * text boxes, paging offset, expanded diff rows, modal visibility). Those are
31 * not "which page am I on" information and would spam the history stack.
32 */
33export interface RepoViewState {
34 /** Top-level repo tab. */
35 tab: RepoTabId
36 /** Selected revision (branch / tag / sha). Empty until the default branch is known. */
37 rev: string
38 /** code: directory being listed · commits: path filter. `''` = root / whole history. */
39 path: string
40 /** code: opened file path (`''` = tree view). */
41 file: string
42 /** Blob oid backing `file`; only the JSON fallback renderer needs it. */
43 oid: string
44 /** One-based code line to reveal after opening `file`; 0 disables highlighting. */
45 line: number
46 /** commits: opened commit sha (`''` = list). */
47 commit: string
48 /** commits: follow first parents only. */
49 firstParent: boolean
50 /** commits: diff layout override; `''` means "follow 用户设置 · 外观". */
51 diff: DiffStyle | ''
52 /** pull: opened PR ref (number or uuid). */
53 pull: string
54 /** pull: sub-tab inside the PR detail. */
55 ptab: PullSubTab
56 /** releases: opened release tag ('' = list). */
57 release: string
58 /** settings: active second-level section. */
59 section: RepoSettingsSection
60}
61
62export const REPO_VIEW_DEFAULTS: RepoViewState = {
63 tab: 'code',
64 rev: '',
65 path: '',
66 file: '',
67 oid: '',
68 line: 0,
69 commit: '',
70 firstParent: false,
71 diff: '',
72 pull: '',
73 ptab: 'conversation',
74 release: '',
75 section: 'general',
76}
77
78const TAB_VALUES: readonly RepoTabId[] = ['code', 'commits', 'pulls', 'pull', 'branches', 'releases', 'settings']
79const SECTION_VALUES = REPO_SETTINGS_SECTIONS.map((section) => section.id)
80const DIFF_VALUES: readonly DiffStyle[] = ['unified', 'split']
81const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks']
82
83/** Query keys owned by the repo page — the only ones this hook may rewrite. */
84const OWNED_KEYS = [
85 'tab',
86 'rev',
87 'path',
88 'file',
89 'oid',
90 'line',
91 'commit',
92 'fp',
93 'diff',
94 'pull',
95 'ptab',
96 'section',
97 'release',
98]
99
100/**
101 * Fields that mean something *inside* a tab. Leaving a tab drops the others,
102 * which is what the previous component-local `useState` versions did by
103 * unmounting: clicking 提交 after browsing into a directory must not silently
104 * filter the history to that directory.
105 */
106const TAB_SCOPE: Record<RepoTabId, readonly (keyof RepoViewState)[]> = {
107 code: ['tab', 'rev', 'path', 'file', 'oid', 'line'],
108 commits: ['tab', 'rev', 'path', 'commit', 'firstParent', 'diff'],
109 pulls: ['tab', 'rev'],
110 pull: ['tab', 'rev', 'pull', 'ptab'],
111 branches: ['tab', 'rev'],
112 releases: ['tab', 'rev', 'release'],
113 settings: ['tab', 'rev', 'section'],
114}
115
116function pick<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
117 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
118}
119
120/** Parse the URL into view state. Unknown values fall back instead of blanking the page. */
121export function readRepoViewState(params: URLSearchParams): RepoViewState {
122 const file = params.get('file') ?? ''
123 return {
124 tab: pick(params.get('tab'), TAB_VALUES, 'code'),
125 rev: params.get('rev') ?? '',
126 path: params.get('path') ?? '',
127 file,
128 oid: file ? (params.get('oid') ?? '') : '',
129 line: file ? Math.max(0, Number.parseInt(params.get('line') ?? '0', 10) || 0) : 0,
130 commit: params.get('commit') ?? '',
131 firstParent: params.get('fp') === '1',
132 diff: pick(params.get('diff'), DIFF_VALUES, '') as DiffStyle | '',
133 pull: params.get('pull') ?? '',
134 ptab: pick(params.get('ptab'), PTAB_VALUES, 'conversation'),
135 release: params.get('release') ?? '',
136 section: pick(params.get('section'), SECTION_VALUES, 'general'),
137 }
138}
139
140/**
141 * Apply a partial update. Changing the tab rebuilds the state from the tab
142 * defaults (only `rev` carries over), then the result is scoped to that tab, so
143 * a tab click always lands on a clean view and no stale foreign param survives.
144 */
145export function applyRepoViewPatch(current: RepoViewState, updates: Partial<RepoViewState>): RepoViewState {
146 const nextTab = updates.tab ?? current.tab
147 const base: RepoViewState =
148 nextTab === current.tab ? current : { ...REPO_VIEW_DEFAULTS, tab: nextTab, rev: current.rev }
149 return scopeToTab({ ...base, ...updates })
150}
151
152/** Default-valued keys are dropped so the common URL stays short. */
153export function writeRepoViewState(base: URLSearchParams, state: RepoViewState): URLSearchParams {
154 const next = new URLSearchParams(base)
155 for (const key of OWNED_KEYS) next.delete(key)
156 if (state.tab !== 'code') next.set('tab', state.tab)
157 if (state.rev) next.set('rev', state.rev)
158 if (state.path) next.set('path', state.path)
159 if (state.file) {
160 next.set('file', state.file)
161 if (state.oid) next.set('oid', state.oid)
162 if (state.line > 0) next.set('line', String(state.line))
163 }
164 if (state.commit) next.set('commit', state.commit)
165 if (state.firstParent) next.set('fp', '1')
166 if (state.diff) next.set('diff', state.diff)
167 if (state.pull) next.set('pull', state.pull)
168 if (state.ptab !== 'conversation') next.set('ptab', state.ptab)
169 if (state.release) next.set('release', state.release)
170 if (state.section !== 'general') next.set('section', state.section)
171 return next
172}
173
174/** Zero out the fields the given tab does not own. */
175function scopeToTab(state: RepoViewState): RepoViewState {
176 const owns = (key: keyof RepoViewState) => TAB_SCOPE[state.tab].includes(key)
177 return {
178 ...state,
179 path: owns('path') ? state.path : '',
180 file: owns('file') ? state.file : '',
181 oid: owns('oid') && state.file ? state.oid : '',
182 line: owns('line') && state.file ? state.line : 0,
183 commit: owns('commit') ? state.commit : '',
184 firstParent: owns('firstParent') ? state.firstParent : false,
185 diff: owns('diff') ? state.diff : '',
186 pull: owns('pull') ? state.pull : '',
187 ptab: owns('ptab') ? state.ptab : 'conversation',
188 section: owns('section') ? state.section : 'general',
189 }
190}
191
192export interface RepoViewLocation {
193 view: RepoViewState
194 /**
195 * Apply a partial update. Changing `tab` rebuilds the state from the tab
196 * defaults (only `rev` carries over), so a tab click always lands on a clean
197 * view of that tab; same-tab updates merge into what is already there.
198 */
199 patch: (updates: Partial<RepoViewState>, options?: { replace?: boolean }) => void
200 setTab: (tab: RepoTabId) => void
201 /** Select a revision; the code tab's directory/file view is reset with it. */
202 setRev: (rev: string) => void
203 /** Directory navigation inside the code tab (also used by the breadcrumbs). */
204 setPath: (path: string) => void
205 openFile: (file: { path: string; oid?: string }) => void
206 closeFile: () => void
207 /** Open a commit's diff from the history list (keeps list filters). */
208 openCommit: (sha: string) => void
209 /** Back to the history list. */
210 closeCommit: () => void
211 /** Show a path's history: jumps to the commits tab with that path filter. */
212 showHistory: (path: string) => void
213 setFirstParent: (on: boolean) => void
214 /** Diff layout for the commit view; `''` follows the saved appearance. */
215 setDiffStyle: (style: DiffStyle | '') => void
216 setSection: (section: RepoSettingsSection) => void
217 /** Branch/tag "browse": switch revision and jump back to the code tab. */
218 browseRev: (rev: string) => void
219 /**
220 * Rebuild the params for a different repository behind the same route
221 * (`/app/x/repo/a` → `/app/x/repo/b`): everything pointing at the old repo's
222 * revision/tree/commit is meaningless, so only `rev` is written.
223 */
224 normalizeForRepo: (rev: string) => void
225}
226
227/** Repo view state as a URL source of truth (no component-local mirrors). */
228export function useRepoViewLocation(): RepoViewLocation {
229 const [params, setParams] = useSearchParams()
230 const view = useMemo(() => readRepoViewState(params), [params])
231
232 const patch = useCallback<RepoViewLocation['patch']>(
233 (updates, options) => {
234 setParams((prev) => writeRepoViewState(prev, applyRepoViewPatch(readRepoViewState(prev), updates)), {
235 replace: options?.replace ?? false,
236 preventScrollReset: true,
237 })
238 },
239 [setParams],
240 )
241
242 const setRev = useCallback<RepoViewLocation['setRev']>(
243 (rev) => {
244 // CodeTab remounts on a revision change (its tree cache is rev-keyed), so
245 // its directory/file view resets; the history list keeps its filters.
246 patch(view.tab === 'code' ? { rev, path: '', file: '', oid: '', line: 0 } : { rev })
247 },
248 [patch, view.tab],
249 )
250
251 const setPath = useCallback<RepoViewLocation['setPath']>(
252 (path) => patch({ path, file: '', oid: '', line: 0 }),
253 [patch],
254 )
255
256 const openFile = useCallback<RepoViewLocation['openFile']>(
257 (file) => patch({ file: file.path, oid: file.oid ?? '', line: 0 }),
258 [patch],
259 )
260
261 const closeFile = useCallback(() => patch({ file: '', oid: '', line: 0 }), [patch])
262
263 const openCommit = useCallback<RepoViewLocation['openCommit']>((sha) => patch({ commit: sha }), [patch])
264
265 const closeCommit = useCallback(() => patch({ commit: '' }), [patch])
266
267 const showHistory = useCallback<RepoViewLocation['showHistory']>(
268 (path) => patch({ tab: 'commits', commit: '', path }),
269 [patch],
270 )
271
272 const setFirstParent = useCallback<RepoViewLocation['setFirstParent']>((on) => patch({ firstParent: on }), [patch])
273
274 const setDiffStyle = useCallback<RepoViewLocation['setDiffStyle']>((diff) => patch({ diff }), [patch])
275
276 const setSection = useCallback<RepoViewLocation['setSection']>((section) => patch({ section }), [patch])
277
278 const browseRev = useCallback<RepoViewLocation['browseRev']>(
279 (rev) => patch({ tab: 'code', rev, path: '', file: '', oid: '', line: 0 }),
280 [patch],
281 )
282
283 const normalizeForRepo = useCallback<RepoViewLocation['normalizeForRepo']>(
284 (rev) =>
285 setParams((prev) => writeRepoViewState(prev, { ...REPO_VIEW_DEFAULTS, rev }), {
286 replace: true,
287 preventScrollReset: true,
288 }),
289 [setParams],
290 )
291
292 return {
293 view,
294 patch,
295 setTab: (tab) => patch({ tab }),
296 setRev,
297 setPath,
298 openFile,
299 closeFile,
300 openCommit,
301 closeCommit,
302 showHistory,
303 setFirstParent,
304 setDiffStyle,
305 setSection,
306 browseRev,
307 normalizeForRepo,
308 }
309}
Added view/app/src/pages/workspace/model/route.ts view/app/src/pages/workspace/model/route.ts +14 −0 View view/app/src/pages/workspace/model/route.ts
@@ -0,0 +1,14 @@
1/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'members' | 'notify' | 'explore'
3
4/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
5export type RepoTabId = 'code' | 'commits' | 'pulls' | 'pull' | 'branches' | 'releases' | 'settings'
6
7/** Parsed route consumed by the workspace page's data loading. */
8export interface ShellRoute {
9 mode: ShellMode
10 spaceName: string
11 roomId?: string
12 boardId?: string
13 repoName?: string
14}
Added view/app/src/pages/workspace/model/shell-api.ts view/app/src/pages/workspace/model/shell-api.ts +65 −0 View view/app/src/pages/workspace/model/shell-api.ts
@@ -0,0 +1,65 @@
1/**
2 * Workspace page data queries: everything the page frame loads per render
3 * (identity, space lists, nav lists, recent messages, repo tags). Space
4 * mutations live in features/space-management; user-account mutations in
5 * features/user-settings.
6 */
7
8import { fetchSessionUser } from '@app/domains/session'
9import { type ApiEnvelope, settingsKey, unwrapApi } from '@app/lib'
10import Apis from '@app/services/api'
11import { writeCache } from '@app/services/storage'
12import type { BoardItem, DmItem, Me, MySpace, RepoItem, RoomBrief, TagItem, UserPreferences } from './workspace'
13
14// Identity goes through the auth slice's probe so a refused session invalidates
15// the cached `me` on both sides: the shell redirects to login, and the auth
16// routes' guest guard does not bounce the visitor straight back into `/app`.
17export const fetchMe = (): Promise<Me> => fetchSessionUser()
18
19/**
20 * The account's preference row, cached under the same key the settings modal
21 * writes so both surfaces read one payload.
22 */
23export const fetchPreferences = () => unwrapApi<UserPreferences>(Apis.account.get_preferences())
24
25/**
26 * Persist one preference (the issue-board view switch writes here). The
27 * response is the canonical row: store it so every other reader of
28 * `settings:preferences` sees the change without a refetch.
29 */
30export const updatePreferences = (cmd: { board_view_mode?: string }) =>
31 unwrapApi<UserPreferences>(Apis.account.update_preferences({ data: cmd })).then((prefs) => {
32 writeCache(settingsKey('preferences'), prefs)
33 return prefs
34 })
35
36export const fetchMySpaces = () => unwrapApi<MySpace[]>(Apis.user.my_spaces({ params: {} }))
37
38export const fetchSpaceRooms = (spaceName: string) =>
39 unwrapApi<RoomBrief[]>(Apis.chat.list_space_rooms({ pathParams: { space_name: spaceName } }))
40
41export const fetchDms = () =>
42 unwrapApi<{ items: DmItem[] }>(Apis.chat.list_dms({ params: { limit: 50 } })).then((p) => p?.items ?? [])
43
44export const fetchSpaceRepos = (spaceName: string) =>
45 unwrapApi<{ items: RepoItem[] }>(
46 Apis.repos.list_repos({ pathParams: { space_name: spaceName }, params: { limit: 50 } }),
47 ).then((p) => p?.items ?? [])
48
49// `list_boards` is typed against the generic `Page` schema; items are `BoardDto`.
50export const fetchSpaceBoards = (spaceName: string) =>
51 unwrapApi<{ items: BoardItem[] }>(
52 Apis.issues.list_boards({
53 pathParams: { space_name: spaceName },
54 params: { limit: 50 },
55 }) as unknown as PromiseLike<ApiEnvelope<{ items: BoardItem[] }>>,
56 ).then((p) => p?.items ?? [])
57
58/** First page of tag names for the repo toolbar chip. */
59export const fetchTags = (spaceName: string, repoName: string) =>
60 unwrapApi<{ items: TagItem[] }>(
61 Apis.tags.list_tags({
62 pathParams: { space_name: spaceName, repo_name: repoName },
63 params: { limit: 10 },
64 }),
65 ).then((p) => p?.items ?? [])
Added view/app/src/pages/workspace/model/workspace-data.ts view/app/src/pages/workspace/model/workspace-data.ts +105 −0 View view/app/src/pages/workspace/model/workspace-data.ts
@@ -0,0 +1,105 @@
1import { fetchSpaceDir } from '@app/domains/space/management'
2import { applyAccountLocale } from '@app/i18n'
3import {
4 dmsKey,
5 meKey,
6 repoKey,
7 spaceBoardsKey,
8 spaceDirKey,
9 spaceReposKey,
10 spaceRoomsKey,
11 spacesKey,
12 useAsync,
13} from '@app/lib'
14import { setCacheOwner } from '@app/services/storage'
15import { useEffect } from 'react'
16import type { ShellRoute } from './route'
17import {
18 fetchDms,
19 fetchMe,
20 fetchMySpaces,
21 fetchSpaceBoards,
22 fetchSpaceRepos,
23 fetchSpaceRooms,
24 fetchTags,
25} from './shell-api'
26
27/**
28 * Everything the shell frame needs from the API: the space list behind the
29 * rail, the sidebar's rooms / DMs / repos / boards, and the selected column's
30 * own data. Lists arrive in parallel; each refetch is keyed off the route.
31 *
32 * Every list is local-first: the previous payload paints from the cache while
33 * the network revalidation runs, and the fresh response replaces it live.
34 */
35export function useWorkspaceData(
36 { spaceName, roomId, boardId, repoName, mode }: ShellRoute,
37 reloadKey = 0,
38 listsReloadKey = 0,
39 meReloadKey = 0,
40) {
41 // The personal scope owns DMs only; space-scoped lists would 404. The
42 // explore column owns no space either, so it skips the same lists.
43 const personal = spaceName === '@me'
44 const scoped = !personal && spaceName !== ''
45 const me = useAsync(fetchMe, [meReloadKey], { cacheKey: meKey() })
46 const spaces = useAsync(fetchMySpaces, [reloadKey], { cacheKey: spacesKey() })
47 const rooms = useAsync(
48 () => (scoped ? fetchSpaceRooms(spaceName) : Promise.resolve([])),
49 [spaceName, listsReloadKey],
50 { cacheKey: scoped ? spaceRoomsKey(spaceName) : null },
51 )
52 const dirs = useAsync(() => (scoped ? fetchSpaceDir(spaceName) : Promise.resolve([])), [spaceName, listsReloadKey], {
53 cacheKey: scoped ? spaceDirKey(spaceName) : null,
54 })
55 const dms = useAsync(fetchDms, [], { cacheKey: dmsKey() })
56 const repos = useAsync(
57 () => (scoped ? fetchSpaceRepos(spaceName) : Promise.resolve([])),
58 [spaceName, listsReloadKey],
59 { cacheKey: scoped ? spaceReposKey(spaceName) : null },
60 )
61 const boards = useAsync(
62 () => (scoped ? fetchSpaceBoards(spaceName) : Promise.resolve([])),
63 [spaceName, listsReloadKey],
64 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
65 )
66 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
67 const tags = useAsync(() => (repoRef ? fetchTags(spaceName, repoName!) : Promise.resolve([])), [repoRef], {
68 cacheKey: repoRef ? repoKey(spaceName, repoName!, 'tags') : null,
69 })
70
71 // The store belongs to one account: once `me` resolves, a different owner
72 // than the recorded one wipes every cached payload before this session
73 // repopulates it (login pages also set the owner eagerly, see auth flow).
74 const meUserId = me.data?.user.user_id
75 useEffect(() => {
76 if (meUserId) void setCacheOwner(String(meUserId))
77 }, [meUserId])
78
79 // The interface language follows the account: `users.language` wins over
80 // `users.locale`, and either may fall through to the device choice when it
81 // names a locale this build does not ship. See `shared/i18n/locale` for the
82 // full precedence chain.
83 const meLanguage = me.data?.user.language
84 const meLocale = me.data?.user.locale
85 useEffect(() => {
86 applyAccountLocale(meLanguage, meLocale)
87 }, [meLanguage, meLocale])
88
89 return {
90 me,
91 spaces,
92 rooms,
93 dirs,
94 dms,
95 repos,
96 boards,
97 tags,
98 repoRef,
99 currentSpace: spaces.data?.find(({ space }) => space.name === spaceName)?.space,
100 room: rooms.data?.find((r) => r.id === roomId),
101 dm: dms.data?.find((d) => d.id === roomId),
102 board: boards.data?.find((b) => b.id === boardId),
103 repo: repos.data?.find((r) => r.name === repoName),
104 }
105}
Added view/app/src/pages/workspace/model/workspace-route.ts view/app/src/pages/workspace/model/workspace-route.ts +47 −0 View view/app/src/pages/workspace/model/workspace-route.ts
@@ -0,0 +1,47 @@
1import { useLocation } from 'react-router-dom'
2import type { ShellMode, ShellRoute } from './route'
3
4/**
5 * URL shape is `/app/{space_name}/{chat|board|repo}/{id?}` (plus the personal
6 * `/app/@me/notify` inbox). The section is a static route segment, so it is
7 * read off the pathname, and so is every dynamic segment: the workspace is a
8 * single layout route (see `app/routes/router.tsx`) whose children only declare
9 * which URLs are valid, so `useParams()` here would always be empty. Parsing the
10 * pathname is what lets the shell survive a section switch without remounting.
11 */
12export function useShellRoute(): ShellRoute {
13 const { pathname } = useLocation()
14 const segments = pathname.split('/')
15 const rawScope = segments[2] ?? ''
16 const rawSection = segments[3] ?? ''
17 // The id belongs to whichever section is in the URL; a section-less space
18 // root (`/app/minimax`) has none, exactly like its optional route segment.
19 const rawId = segments[4] || undefined
20 const decode = (value: string | undefined) => (value === undefined ? undefined : decodeURIComponent(value))
21 // `/app/explore` is a static sibling of `/app/:spaceName`, so it has no
22 // section segment: the second path segment itself is the marker.
23 const explore = rawScope === 'explore'
24 const section = decodeURIComponent(rawSection)
25 const mode: ShellMode = explore
26 ? 'explore'
27 : section === 'notify'
28 ? 'notify'
29 : section === 'board'
30 ? 'board'
31 : section === 'members'
32 ? 'members'
33 : section === 'repo'
34 ? 'repo'
35 : 'chat'
36 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
37 // data loading and the rail can treat it like any other scope.
38 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
39
40 return {
41 mode,
42 spaceName: explore ? '' : (decode(rawScope) ?? (personal ? '@me' : '')),
43 roomId: section === 'chat' ? decode(rawId) : undefined,
44 boardId: section === 'board' ? decode(rawId) : undefined,
45 repoName: section === 'repo' ? decode(rawId) : undefined,
46 }
47}
Added view/app/src/pages/workspace/model/workspace.ts view/app/src/pages/workspace/model/workspace.ts +34 −0 View view/app/src/pages/workspace/model/workspace.ts
@@ -0,0 +1,34 @@
1/**
2 * Workspace row shapes read straight out of the generated response envelopes,
3 * so the page never names a schema version and follows the API types instead.
4 */
5import type { DmBrief, RoomBrief } from '@app/domains/chat/model'
6import type {
7 EnvelopeBoardDto,
8 EnvelopeInviteSummary,
9 EnvelopeMeResp,
10 EnvelopePageMySpaceDto,
11 EnvelopePageRepoSummaryDto,
12 EnvelopeSpaceDto,
13 EnvelopeUserPreferencesDto,
14 EnvelopeVecDirNodeDto,
15} from '@app/services/api'
16
17export type MySpace = NonNullable<EnvelopePageMySpaceDto['data']>['items'][number]
18// Chat rows come from entities/chat: the sidebar and the room view must read the
19// same shape, and a second alias of the same envelope would only drift.
20export type { RoomBrief }
21export type DmItem = DmBrief
22export type RepoItem = NonNullable<NonNullable<EnvelopePageRepoSummaryDto['data']>['items']>[number]
23export type BoardItem = NonNullable<EnvelopeBoardDto['data']>
24export type Me = NonNullable<EnvelopeMeResp['data']>
25export type SpaceDto = NonNullable<EnvelopeSpaceDto['data']>
26export type Invite = NonNullable<EnvelopeInviteSummary['data']>
27/** The signed-in user's preference row — the same payload the settings modal edits. */
28export type UserPreferences = NonNullable<EnvelopeUserPreferencesDto['data']>
29
30/** Directory node of the sidebar asset tree (rooms/repos/boards nested under dirs). */
31export type DirNode = NonNullable<EnvelopeVecDirNodeDto['data']>[number]
32
33/** Repo tag, as shown by the repo toolbar's ref chip. */
34export type TagItem = { name: string }
Added view/app/src/pages/workspace/ui/board/board-view.tsx view/app/src/pages/workspace/ui/board/board-view.tsx +268 −0 View view/app/src/pages/workspace/ui/board/board-view.tsx
@@ -0,0 +1,268 @@
1import {
2 BoardActions,
3 BoardFilters,
4 BoardManageModal,
5 BoardSkeleton,
6 BoardViewTabs,
7 ISSUE_PAGE_SIZE,
8 IssueBoardBody,
9 IssueDetail,
10 IssueFormModal,
11 useBoardData,
12} from '@app/domains/issues'
13import type {
14 Board,
15 IssueBoardView,
16 IssueDetail as IssueDetailDto,
17 IssuePriority,
18 IssueQuery,
19 UserDto,
20} from '@app/domains/issues/model'
21import { issueRef, normalizeBoardView } from '@app/domains/issues/model'
22import { t as tr, useT } from '@app/i18n'
23import { settingsKey, useAsync } from '@app/lib'
24import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
25import { useNavigate } from 'react-router-dom'
26import { useBoardViewLocation } from '../../model/board-location'
27import { fetchPreferences, updatePreferences } from '../../model/shell-api'
28import { BoardLayout } from '../layout/board-layout'
29
30export interface BoardViewProps {
31 spaceName: string
32 boardId: string
33 /** Row from the sidebar's board list — titles the frame before the fetch lands. */
34 boardHint?: Board
35 viewerId?: number
36 /** AppShell slots owned by the workspace frame. */
37 shell: {
38 rail?: React.ReactNode
39 sideHeader?: React.ReactNode
40 navPinned?: React.ReactNode
41 nav?: React.ReactNode
42 sideFooter?: React.ReactNode
43 }
44 /** Sidebar counts (open/closed issues) come from the shell's own query. */
45 onCountsChanged: () => void
46}
47
48/** Distinguish "wrong link" from "no access" from "backend said no". */
49function boardGoneMessage(code: number | undefined): string {
50 if (code === 404) return tr('shell.board.notFound')
51 if (code === 403) return tr('shell.board.forbidden')
52 if (code === 401) return tr('error.status.401')
53 return tr('shell.board.loadFailed')
54}
55
56/**
57 * Route-level issue-board page. The URL holds the whole view state (see
58 * `model/board-location`), so this component owns no mirrors of it: it derives
59 * the query, hands data + callbacks to the feature views, and keeps the two
60 * dialogs (issue form / board management) as single instances.
61 */
62export function BoardView({ spaceName, boardId, boardHint, viewerId, shell, onCountsChanged }: BoardViewProps) {
63 const { t } = useT()
64 const navigate = useNavigate()
65 // 走哪一层视图是用户的偏好:URL 没有 `?view=` 时按 `board_view_mode` 起手,
66 // 用户在工具栏切换时写回偏好(下一块问题集也按新视图打开)。
67 const prefs = useAsync(fetchPreferences, [], { cacheKey: settingsKey('preferences') })
68 const loc = useBoardViewLocation(normalizeBoardView(prefs.data?.board_view_mode))
69 const { view } = loc
70 const [reload, setReload] = useState(0)
71 const [creating, setCreating] = useState<{ priority?: IssuePriority } | null>(null)
72 const [editing, setEditing] = useState<IssueDetailDto | null>(null)
73 const [managing, setManaging] = useState(false)
74
75 /**
76 * The 看板/列表 switch is a persisted preference, not just URL state: flipping
77 * it here is the user saying "this is how I read problem sets", so the next
78 * board opens the same way. Only a real change is written — re-clicking the
79 * active view, or a value the URL already pinned, must not rewrite the
80 * account's default.
81 */
82 const setFilter = useCallback(
83 (updates: Partial<IssueBoardView>) => {
84 loc.setFilter(updates)
85 const next = updates.view
86 // Only a click that actually changes the layout is a statement about the
87 // preference; merely rendering a pinned `?view=` link is not.
88 if (!next || next === view.view) return
89 void updatePreferences({ board_view_mode: next })
90 },
91 [loc, view.view],
92 )
93 /**
94 * The layout itself is unknown until the preference lands, and painting the
95 * wrong one would flash a five-column track at a reader whose default is the
96 * list. A URL that pins `?view=` needs nothing, so the wait only applies to
97 * the inherited case.
98 */
99 const viewPending = !loc.pinnedView && !prefs.data && prefs.loading
100
101 const boardRef = useMemo(() => ({ space: spaceName, board: boardId }), [spaceName, boardId])
102 const query = useMemo<IssueQuery>(
103 () => ({
104 state: view.state,
105 q: view.q || undefined,
106 priorities: view.priorities.length > 0 ? view.priorities : undefined,
107 labelIds: view.labelIds.length > 0 ? view.labelIds : undefined,
108 milestoneIds: view.milestoneIds.length > 0 ? view.milestoneIds : undefined,
109 assigneeIds: view.assigneeIds.length > 0 ? view.assigneeIds : undefined,
110 sort: view.sort,
111 limit: ISSUE_PAGE_SIZE,
112 offset: (view.page - 1) * ISSUE_PAGE_SIZE,
113 }),
114 [view],
115 )
116 const data = useBoardData(boardRef, query, reload)
117
118 const onChanged = useCallback(() => {
119 setReload((key) => key + 1)
120 onCountsChanged()
121 }, [onCountsChanged])
122
123 // Switching board inside the same route component must not carry the previous
124 // board's filters or `?issue=` over; a fresh mount keeps the URL as typed.
125 const lastBoard = useRef<string | null>(null)
126 useEffect(() => {
127 if (lastBoard.current !== null && lastBoard.current !== boardId) loc.normalizeForBoard()
128 lastBoard.current = boardId
129 // eslint-disable-next-line react-hooks/exhaustive-deps
130 }, [boardId])
131
132 const board = data.board.data ?? boardHint
133 // Assignee pickers and the 负责人 filter share one source: the space's members.
134 const members = (data.members.data?.items ?? [])
135 .map((member) => member.user)
136 .filter((user): user is UserDto => Boolean(user))
137 const boardFailure = data.board.error as { code?: number; message?: string } | undefined
138
139 if (!boardId) {
140 return (
141 <BoardLayout {...shell} title={t('shell.board.title')} variant="detail">
142 <div className="w-full rounded-lg border-2 border-dashed border-shell-hairline bg-shell-board px-4 py-16 text-center text-[13px] text-shell-ink-muted">
143 {t('shell.board.pick')}
144 </div>
145 </BoardLayout>
146 )
147 }
148
149 // The board itself is gone (deleted elsewhere) or was never visible: nothing
150 // below it can work, so replace the whole content column.
151 if (boardFailure && !board) {
152 return (
153 <BoardLayout {...shell} title={t('shell.board.title')} variant="detail">
154 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-16 text-center">
155 <p className="text-[15px] font-semibold">{boardGoneMessage(boardFailure.code)}</p>
156 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">
157 {boardFailure.message ?? t('shell.board.deletedHint')}
158 </p>
159 <div className="flex items-center gap-2">
160 <button
161 type="button"
162 onClick={() => setReload((key) => key + 1)}
163 className="rounded-md border-2 border-shell-hairline bg-shell-field px-3.5 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
164 >
165 {t('common.action.retry')}
166 </button>
167 <button
168 type="button"
169 onClick={() => navigate(`/app/${encodeURIComponent(spaceName)}`)}
170 className="rounded-md bg-shell-action px-3.5 py-1.5 text-[13px] font-semibold text-white transition-[filter] hover:brightness-95"
171 >
172 {t('shell.board.return')}
173 </button>
174 </div>
175 </div>
176 </BoardLayout>
177 )
178 }
179
180 return (
181 <>
182 <BoardLayout
183 {...shell}
184 title={board?.name ?? t('shell.board.title')}
185 topic={board?.description ?? undefined}
186 variant={view.issue ? 'detail' : 'columns'}
187 tabs={<BoardViewTabs value={view.view} onChange={loc.setView} />}
188 actions={<BoardActions onCreate={() => setCreating({})} />}
189 toolbar={
190 <BoardFilters
191 view={view}
192 total={data.issues.data?.total ?? 0}
193 filtered={loc.filtered}
194 labels={data.labels.data?.items ?? []}
195 labelsFailed={Boolean(data.labels.error)}
196 milestones={data.milestones.data?.items ?? []}
197 members={members}
198 onFilter={setFilter}
199 onClear={loc.clearFilters}
200 onCreate={() => setCreating({})}
201 onManage={() => setManaging(true)}
202 />
203 }
204 >
205 {view.issue ? (
206 <IssueDetail
207 key={view.issue}
208 boardRef={boardRef}
209 issueId={view.issue}
210 reloadKey={reload}
211 data={data}
212 viewerId={viewerId}
213 onBack={loc.closeIssue}
214 onChanged={onChanged}
215 onEdit={(issue) => setEditing(issue)}
216 />
217 ) : viewPending ? (
218 <BoardSkeleton columns={1} />
219 ) : (
220 <IssueBoardBody
221 boardRef={boardRef}
222 data={data}
223 view={view}
224 filtered={loc.filtered}
225 onFilter={setFilter}
226 onClear={loc.clearFilters}
227 onOpenIssue={loc.openIssue}
228 onCreate={(preset) => setCreating(preset ?? {})}
229 onChanged={onChanged}
230 onRetry={() => setReload((key) => key + 1)}
231 />
232 )}
233 </BoardLayout>
234
235 <IssueFormModal
236 open={creating !== null || editing !== null}
237 boardRef={boardRef}
238 data={data}
239 editing={editing}
240 presetPriority={creating?.priority}
241 onClose={() => {
242 setCreating(null)
243 setEditing(null)
244 }}
245 onSaved={(issue) => {
246 setCreating(null)
247 setEditing(null)
248 onChanged()
249 // A new issue opens immediately: the author must see what they wrote.
250 if (!editing) loc.openIssue(issueRef(board, issue.number))
251 }}
252 />
253
254 <BoardManageModal
255 open={managing}
256 boardRef={boardRef}
257 board={board}
258 data={data}
259 onClose={() => setManaging(false)}
260 onChanged={onChanged}
261 onDeleted={() => {
262 setManaging(false)
263 navigate(`/app/${encodeURIComponent(spaceName)}`)
264 }}
265 />
266 </>
267 )
268}
Added view/app/src/pages/workspace/ui/chat/chat-view.tsx view/app/src/pages/workspace/ui/chat/chat-view.tsx +114 −0 View view/app/src/pages/workspace/ui/chat/chat-view.tsx
@@ -0,0 +1,114 @@
1/**
2 * Route-level 聊天室 page. The URL owns which panel, thread and one-shot target
3 * is active (see `model/chat-location`); this component only adapts that to the
4 * feature view, resolves the room's identity (space channel vs personal DM) and
5 * owns the two things a channel view cannot do itself: navigate, and ask the
6 * shell to refresh its unread badges.
7 */
8
9import { ChatRoom, type ChatViewState } from '@app/domains/chat'
10import type { DmBrief, RoomBrief } from '@app/domains/chat/model'
11import { useCallback } from 'react'
12import { useNavigate } from 'react-router-dom'
13import { useChatViewLocation } from '../../model/chat-location'
14import { SpaceAssetMenu } from '../asset-menu'
15import { ChatLayout } from '../layout/chat-layout'
16
17export interface ChatViewProps {
18 spaceName: string
19 roomId: string
20 viewerId?: number
21 /** Sidebar rows: they title the frame before `GET /chat/rooms/{id}` lands. */
22 roomHint?: RoomBrief
23 dmHint?: DmBrief
24 canManageSpace?: boolean
25 /** AppShell slots owned by the workspace frame. */
26 shell: {
27 rail?: React.ReactNode
28 sideHeader?: React.ReactNode
29 navPinned?: React.ReactNode
30 nav?: React.ReactNode
31 sideFooter?: React.ReactNode
32 }
33 /** The unread cursor moved: the shell refetches the sidebar badges. */
34 onBadgesChanged: () => void
35}
36
37/** Params that are applied once and then erased from the URL. */
38const ONE_SHOT_KEYS: readonly (keyof ChatViewState)[] = ['replyToId', 'editId', 'jumpId']
39
40export function ChatView({
41 spaceName,
42 roomId,
43 viewerId,
44 roomHint,
45 dmHint,
46 canManageSpace,
47 shell,
48 onBadgesChanged,
49}: ChatViewProps) {
50 const navigate = useNavigate()
51 const loc = useChatViewLocation()
52
53 // Nothing to reset on a channel switch: `chat-location` only persists `thread`
54 // alongside `panel=thread` and `q` alongside `panel=search`, and every sidebar
55 // link is a bare `/chat/<id>`, so a panel can never leak into the next room —
56 // while a deliberate `?jump=` deep link survives exactly as typed.
57
58 const patch = useCallback(
59 (updates: Partial<ChatViewState>) => {
60 // A one-shot target is *replaced*: leaving a back-button stop for every row
61 // the reader clicked would make history useless.
62 const onlyOneShot = Object.keys(updates).every((key) => ONE_SHOT_KEYS.includes(key as keyof ChatViewState))
63 loc.patch(updates, { replace: onlyOneShot })
64 },
65 [loc],
66 )
67
68 // The feature performs the `POST /chat/dms` write (and reports its failure);
69 // the page only reacts to the room id that came back.
70 const onDmOpened = useCallback(
71 (dmRoomId: string) => {
72 onBadgesChanged()
73 navigate(`/app/@me/chat/${dmRoomId}`)
74 },
75 [navigate, onBadgesChanged],
76 )
77
78 const onRoomGone = useCallback(
79 (reason: 'deleted' | 'left' | 'dm_closed') => {
80 onBadgesChanged()
81 navigate(reason === 'dm_closed' ? '/app/@me' : `/app/${encodeURIComponent(spaceName)}`, {
82 replace: true,
83 })
84 },
85 [navigate, onBadgesChanged, spaceName],
86 )
87
88 return (
89 <ChatLayout {...shell}>
90 {/* Keyed by room: the transcript, draft and panel state are per-room, and a
91 remount is cheaper and safer than hand-resetting all of it. */}
92 <ChatRoom
93 key={roomId}
94 spaceName={spaceName}
95 roomId={roomId}
96 viewerId={viewerId}
97 titleMenu={spaceName !== '@me' && spaceName !== '' ? (node) => <SpaceAssetMenu title={node} /> : undefined}
98 hint={
99 roomHint
100 ? { name: roomHint.name, topic: roomHint.topic ?? null, kind: roomHint.kind }
101 : dmHint
102 ? { name: dmHint.name, topic: null, kind: dmHint.kind, peer: dmHint.peer ?? null }
103 : undefined
104 }
105 canManageSpace={canManageSpace}
106 state={loc.view}
107 onStateChange={patch}
108 onUnreadChanged={onBadgesChanged}
109 onRoomGone={onRoomGone}
110 onDmOpened={onDmOpened}
111 />
112 </ChatLayout>
113 )
114}
Added view/app/src/pages/workspace/ui/layout/board-layout.tsx view/app/src/pages/workspace/ui/layout/board-layout.tsx +162 −0 View view/app/src/pages/workspace/ui/layout/board-layout.tsx
@@ -0,0 +1,162 @@
1import { AppShell, type AppShellProps, ShellTopBar } from '@app/blocks'
2import { useT } from '@app/i18n'
3import { Plus, SquareKanban } from 'lucide-react'
4import type { ReactNode } from 'react'
5import { SpaceAssetMenu } from '../asset-menu'
6
7/**
8 * Issue-board main-column layout (design/layout.pen “GitData · 工单看板”):
9 * 52px top bar + 56px filter toolbar + horizontally scrolling columns.
10 * Columns are 266px wide with a tinted track; cards plug in as children.
11 */
12
13export interface BoardLayoutProps
14 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
15 title?: ReactNode
16 topic?: ReactNode
17 /** Page-level view switch (看板 / 列表), rendered beside the title. */
18 tabs?: ReactNode
19 actions?: ReactNode
20 /** Toolbar left segment (filter chips). Defaults to skeleton chips. */
21 toolbar?: ReactNode
22 /** Toolbar right segment (watchers + primary action). */
23 toolbarActions?: ReactNode
24 /** Column content; each child should be a `BoardColumn`. */
25 children?: ReactNode
26 /**
27 * `columns` (default) is the kanban track: horizontally scrolling 266px
28 * columns. `detail` switches the main column to a single vertical scroller,
29 * used when one issue is open.
30 */
31 variant?: 'columns' | 'detail'
32}
33
34export function BoardLayout({
35 title,
36 topic,
37 tabs,
38 actions,
39 toolbar,
40 toolbarActions,
41 children,
42 variant = 'columns',
43 ...shell
44}: BoardLayoutProps) {
45 const { t } = useT()
46 return (
47 <AppShell {...shell}>
48 <ShellTopBar
49 icon={<SquareKanban className="h-[18px] w-[18px] shrink-0" />}
50 title={title}
51 titleMenu={<SpaceAssetMenu title={title} />}
52 topic={topic}
53 tabs={tabs}
54 actions={actions}
55 />
56 <div className="board-toolbar flex min-h-14 shrink-0 flex-wrap items-center gap-2 px-4 py-2">
57 {toolbar ?? (
58 <>
59 <span className="h-7 w-36 rounded-md bg-shell-field" />
60 <span className="h-7 w-28 rounded-md bg-shell-field" />
61 <span className="h-7 w-24 rounded-md bg-shell-field" />
62 </>
63 )}
64 {toolbarActions || !toolbar ? (
65 <div className="board-toolbar-actions ml-auto flex items-center gap-3">
66 {toolbarActions ?? (
67 <>
68 <div className="flex -space-x-1.5">
69 {['#ca6a8f', '#bd923d', '#4f9874', '#66717d'].map((c) => (
70 <span
71 key={c}
72 className="h-6 w-6 rounded-full ring-2 ring-shell-surface"
73 style={{ backgroundColor: c }}
74 />
75 ))}
76 </div>
77 <span className="flex h-[30px] items-center gap-1 rounded-md bg-shell-action px-3 text-[13px] font-semibold text-white">
78 <Plus className="h-3.5 w-3.5" />
79 {t('shell.board.newIssue')}
80 </span>
81 </>
82 )}
83 </div>
84 ) : null}
85 </div>
86 {variant === 'detail' ? (
87 <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-1">{children}</div>
88 ) : (
89 <div className="board-canvas min-h-0 flex-1 overflow-x-auto px-4 pb-4 pt-1">
90 <div className="board-track flex h-full gap-3">{children ?? <BoardColumnsPlaceholder />}</div>
91 </div>
92 )}
93 </AppShell>
94 )
95}
96
97export interface BoardColumnProps {
98 title: ReactNode
99 count?: ReactNode
100 /** Status dot color; matches the board's column semantics. */
101 dot?: string
102 children?: ReactNode
103}
104
105/** 266px kanban column: header band + scrolling card stack on a tinted track. */
106export function BoardColumn({ title, count, dot = '#8b8f99', children }: BoardColumnProps) {
107 return (
108 <section className="flex h-full w-[266px] shrink-0 flex-col gap-2 rounded-lg bg-shell-board p-2.5">
109 <header className="flex h-6 shrink-0 items-center gap-2">
110 <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: dot }} />
111 <span className="text-[14px] font-semibold">{title}</span>
112 <span className="text-[12px] text-shell-ink-muted">{count}</span>
113 <Plus className="ml-auto h-3.5 w-3.5 text-shell-ink-muted" />
114 </header>
115 <div className="min-h-0 flex-1 space-y-2 overflow-y-auto">{children}</div>
116 </section>
117 )
118}
119
120function ColumnCards({ n, tint }: { n: number; tint?: string }) {
121 const { t } = useT()
122 return (
123 <>
124 {Array.from({ length: n }, (_, i) => (
125 <div key={i} className="space-y-2 rounded-lg bg-shell-surface p-3 shadow-[0_1px_2px_rgb(28_30_34/8%)]">
126 <span
127 className="block h-[18px] w-[52px] rounded-full text-[11px] font-semibold leading-[18px] text-center"
128 style={tint ? { backgroundColor: tint, color: '#fff' } : undefined}
129 >
130 {tint ? t('issue.board.filter.label') : null}
131 </span>
132 <div className="h-4 rounded-full bg-shell-active" />
133 <div className="h-4 w-2/3 rounded-full bg-shell-active" />
134 <div className="flex items-center gap-2 pt-1">
135 <span className="h-[18px] w-[18px] rounded-full bg-shell-tile" />
136 <span className="h-3 w-16 rounded-full bg-shell-field" />
137 </div>
138 </div>
139 ))}
140 </>
141 )
142}
143
144function BoardColumnsPlaceholder() {
145 const { t } = useT()
146 return (
147 <>
148 <BoardColumn title={t('shell.board.colPending')} count="4" dot="#8b8f99">
149 <ColumnCards n={2} />
150 </BoardColumn>
151 <BoardColumn title={t('shell.board.colInProgress')} count="2" dot="#66717d">
152 <ColumnCards n={2} tint="#eb459e" />
153 </BoardColumn>
154 <BoardColumn title={t('shell.board.colReview')} count="2" dot="#f0b232">
155 <ColumnCards n={2} />
156 </BoardColumn>
157 <BoardColumn title={t('shell.board.colDone')} count="12" dot="#3ba55d">
158 <ColumnCards n={1} />
159 </BoardColumn>
160 </>
161 )
162}
Added view/app/src/pages/workspace/ui/layout/chat-layout.tsx view/app/src/pages/workspace/ui/layout/chat-layout.tsx +38 −0 View view/app/src/pages/workspace/ui/layout/chat-layout.tsx
@@ -0,0 +1,38 @@
1import { AppShell, type AppShellProps, ShellTopBar } from '@app/blocks'
2import { Hash } from 'lucide-react'
3import { SpaceAssetMenu } from '../asset-menu'
4
5/**
6 * Chat main-column layout (design/layout.pen “Discord App”).
7 *
8 * The `children` slot owns its own scroll containers: a chat column has two
9 * independently scrolling regions (transcript and optional right panel) plus a
10 * pinned composer band, and letting the document scroll instead would break both
11 * the "pinned to the tail" read cursor and the older-page scroll anchor.
12 */
13
14export interface ChatLayoutProps
15 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
16 /** Top-bar title slot (the room view renders its own header row). */
17 title?: React.ReactNode
18 topic?: React.ReactNode
19 actions?: React.ReactNode
20 children: React.ReactNode
21}
22
23export function ChatLayout({ title, topic, actions, children, ...shell }: ChatLayoutProps) {
24 return (
25 <AppShell {...shell}>
26 {title ? (
27 <ShellTopBar
28 icon={<Hash className="h-[18px] w-[18px] shrink-0" />}
29 title={title}
30 titleMenu={<SpaceAssetMenu title={title} />}
31 topic={topic}
32 actions={actions}
33 />
34 ) : null}
35 <div className="flex min-h-0 flex-1">{children}</div>
36 </AppShell>
37 )
38}
Added view/app/src/pages/workspace/ui/layout/repo-layout.tsx view/app/src/pages/workspace/ui/layout/repo-layout.tsx +495 −0 View view/app/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -0,0 +1,495 @@
1import { AppShell, type AppShellProps, ShellTopBar } from '@app/blocks'
2import { type MessageKey, useT } from '@app/i18n'
3import {
4 ChevronDown,
5 Code,
6 File,
7 Folder,
8 FolderGit2,
9 GitBranch,
10 GitCommitHorizontal,
11 GitPullRequest,
12 Info,
13 type LucideIcon,
14 MoreHorizontal,
15 Settings,
16 Tag,
17} from 'lucide-react'
18import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
19import type { RepoTabId } from '../../model/route'
20import { SpaceAssetMenu } from '../asset-menu'
21
22/**
23 * Repository-browsing main-column layout (design/layout.pen “GitData · 仓库浏览”):
24 * 52px top bar + repo toolbar (refs / tabs / clone) + content split
25 * with a flexible file area and a fixed 280px info aside.
26 */
27
28// The tab ids belong to the route model (they are URL state, see
29// `model/repo-location`), so this layout only consumes them.
30
31export interface RepoLayoutProps
32 extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
33 title?: ReactNode
34 topic?: ReactNode
35 actions?: ReactNode
36 /** Repo toolbar tabs segment; defaults to the designed 代码/工单/流水线/设置 set. */
37 tabs?: ReactNode
38 /** Active tab id when using the default tabs. */
39 activeTab?: RepoTabId
40 /** Tab switch handler for the default tabs. */
41 onTabChange?: (tab: RepoTabId) => void
42 /** Repository administration (the 设置 tab). Server-side every repo
43 * settings endpoint is space-owner / repo-admin only, so the page passes
44 * false when the viewer is neither. Defaults to the least privilege. */
45 canAdmin?: boolean
46 /** 打开的合并请求数:合并请求页签的数字徽标;0 或缺省时不显示。 */
47 pullsCount?: number
48 /** Interactive branch/tag picker replacing the static ref chips. */
49 refsSlot?: ReactNode
50 /** Show the toolbar refs row; hidden by default because the pages render the
51 * refs picker with their own content instead. */
52 showRefs?: boolean
53 /** Right segment of the repo toolbar (clone box). */
54 clone?: ReactNode
55 /** Default branch shown on the branch chip. */
56 branch?: ReactNode
57 /** Latest tag shown on the ref chip. */
58 ref?: ReactNode
59 /** File/browser area (flex-1). */
60 children?: ReactNode
61 /** 280px info aside. */
62 aside?: ReactNode
63 /** 仅代码页签展示右侧仓库概览;false 时整栏与工具栏开关都不渲染。 */
64 showAside?: boolean
65 /** 仓库设置里的页签开关与顺序(缺省 = 全部可见、站点默认顺序)。 */
66 tabOptions?: RepoTabOptions
67}
68
69/** 仓库设置可配置的页签:开关到底是否可见、以及中间页签的顺序。 */
70export interface RepoTabOptions {
71 allowPullRequests: boolean
72 /** 中间页签顺序(提交/合并请求/分支/发布);空 = 默认。 */
73 order: readonly string[]
74}
75
76export function RepoLayout({
77 title = 'gitdata',
78 topic,
79 actions,
80 tabs,
81 activeTab = 'code',
82 onTabChange,
83 canAdmin = false,
84 pullsCount,
85 refsSlot,
86 showRefs = false,
87 clone,
88 branch,
89 ref,
90 children,
91 aside,
92 showAside = true,
93 tabOptions,
94 ...shell
95}: RepoLayoutProps) {
96 const { t } = useT()
97 const [overviewOpen, setOverviewOpen] = useState(false)
98 return (
99 <AppShell {...shell}>
100 <ShellTopBar
101 icon={<FolderGit2 className="h-[18px] w-[18px] shrink-0" />}
102 title={title}
103 titleMenu={<SpaceAssetMenu title={title} />}
104 topic={topic}
105 actions={actions}
106 />
107 <div className="repo-toolbar shrink-0">
108 {showRefs ? (
109 <div className="repo-toolbar-refs">
110 {refsSlot ?? (
111 <>
112 <span className="flex h-[30px] shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium">
113 <GitBranch className="h-3.5 w-3.5 text-shell-ink-muted" />
114 {branch ?? '—'}
115 <ChevronDown className="h-3 w-3 text-shell-ink-muted" />
116 </span>
117 <span className="flex h-[30px] shrink-0 items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium">
118 <Tag className="h-3.5 w-3.5 text-shell-ink-muted" />
119 {ref ?? '—'}
120 <ChevronDown className="h-3 w-3 text-shell-ink-muted" />
121 </span>
122 </>
123 )}
124 </div>
125 ) : null}
126 <div className="repo-toolbar-tabs">
127 {tabs ?? (
128 <DefaultTabs
129 active={activeTab}
130 onChange={onTabChange}
131 canAdmin={canAdmin}
132 tabOptions={tabOptions}
133 pullsCount={pullsCount}
134 />
135 )}
136 </div>
137 <div className={clone ? 'repo-toolbar-clone' : 'repo-toolbar-clone empty'}>
138 {showAside ? (
139 <button
140 type="button"
141 className="repo-overview-toggle workspace-icon-button"
142 title={t('shell.repo.overview')}
143 aria-label={t('shell.repo.overview')}
144 aria-expanded={overviewOpen}
145 onClick={() => setOverviewOpen((value) => !value)}
146 >
147 <Info size={18} />
148 </button>
149 ) : null}
150 {clone}
151 </div>
152 </div>
153 <div className={`repo-content-layout ${overviewOpen ? 'overview-open' : ''}`}>
154 <section data-repo-scroll="" className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden pt-2">
155 {children ?? <FilesPlaceholder />}
156 </section>
157 {showAside ? (
158 <aside className="w-[280px] shrink-0 space-y-3.5 overflow-y-auto pt-2">
159 {aside ?? <RepoAsidePlaceholder />}
160 </aside>
161 ) : null}
162 </div>
163 </AppShell>
164 )
165}
166
167const tabLabels: Array<[NonNullable<RepoLayoutProps['activeTab']>, MessageKey]> = [
168 ['code', 'shell.repo.tabs.code'],
169 ['commits', 'shell.repo.tabs.commits'],
170 ['pulls', 'shell.repo.tabs.pulls'],
171 ['branches', 'shell.repo.tabs.branches'],
172 ['releases', 'shell.repo.tabs.releases'],
173 ['settings', 'shell.repo.tabs.settings'],
174]
175
176/** The PR detail route (`?tab=pull`) belongs to the 合并请求 tab. */
177const tabGroup = (id: RepoTabId): RepoTabId => (id === 'pull' ? 'pulls' : id)
178
179const TAB_LABEL_KEYS = new Map<RepoTabId, MessageKey>(tabLabels)
180
181/** 可由仓库设置重排序的中间页签(代码固定最前、设置固定最后)。 */
182const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'releases']
183
184/** 归一化仓库设置里的顺序:未知/重复项忽略,缺失项按默认顺序补齐。 */
185function orderTabs(order: readonly string[] | undefined): RepoTabId[] {
186 const out: RepoTabId[] = []
187 for (const id of order ?? []) {
188 const tab = id as RepoTabId
189 if (REORDERABLE_TABS.includes(tab) && !out.includes(tab)) out.push(tab)
190 }
191 for (const id of REORDERABLE_TABS) if (!out.includes(id)) out.push(id)
192 return out
193}
194
195/** One label's chrome; the probe reuses it so measured widths match the real
196 * buttons exactly (the active variant only swaps colours and adds the bar). */
197const TAB_ITEM_CLASS =
198 'relative flex h-full shrink-0 items-center gap-1.5 whitespace-nowrap px-3 text-[13.5px] font-medium transition-colors'
199
200const tabItemClass = (isActive: boolean) =>
201 `${TAB_ITEM_CLASS} ${isActive ? 'text-[#ff8400]' : 'text-shell-ink-muted hover:text-shell-ink'}`
202
203/** 页签图标;`pull` 是合并请求详情的路由 id,与 `pulls` 用同一图标。 */
204const TAB_ICONS: Record<RepoTabId, LucideIcon> = {
205 code: Code,
206 commits: GitCommitHorizontal,
207 pulls: GitPullRequest,
208 pull: GitPullRequest,
209 branches: GitBranch,
210 releases: Tag,
211 settings: Settings,
212}
213
214/** 激活页签的底部指示条。 */
215function TabIndicator() {
216 return <span aria-hidden className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-[#ff8400]" />
217}
218
219/** 页签内容(图标 + 文案 + 徽标);探针复用同一份渲染,量出的宽度才和真实按钮一致。 */
220function TabContent({ id, labelKey, pullsCount }: { id: RepoTabId; labelKey: MessageKey; pullsCount?: number }) {
221 const { t } = useT()
222 const Icon = TAB_ICONS[id]
223 const count = id === 'pulls' ? (pullsCount ?? 0) : 0
224 return (
225 <>
226 <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
227 <span>{t(labelKey)}</span>
228 {count > 0 ? (
229 <span className="min-w-[18px] rounded-full bg-shell-field px-1 text-center text-[11px] font-semibold leading-[18px] text-shell-ink-muted">
230 {count > 99 ? '99+' : count}
231 </span>
232 ) : null}
233 </>
234 )
235}
236
237/** Width reserved for the "…" trigger when part of the strip is hidden. */
238const MORE_WIDTH = 28
239
240/**
241 * Repository section tabs. A narrow main column cannot show every label, so the
242 * strip measures each one and hands whatever does not fit to a "…" menu instead
243 * of scrolling it out of reach. The section being viewed always keeps a slot:
244 * when it would fall past the edge it swaps into the last one, so the current
245 * tab never hides behind the menu.
246 */
247function DefaultTabs({
248 active,
249 onChange,
250 canAdmin,
251 tabOptions,
252 pullsCount,
253}: {
254 active: NonNullable<RepoLayoutProps['activeTab']>
255 onChange?: (tab: RepoTabId) => void
256 canAdmin: boolean
257 tabOptions?: RepoTabOptions
258 pullsCount?: number
259}) {
260 const { t, locale } = useT()
261 const stripRef = useRef<HTMLElement>(null)
262 const probeRef = useRef<HTMLElement>(null)
263 const menuRef = useRef<HTMLDivElement>(null)
264 const triggerRef = useRef<HTMLButtonElement>(null)
265 // 页签条 = 代码(固定)+ 仓库设置里开关/排序过的中间页签 + 设置(按权限裁剪)。
266 const labels = useMemo<Array<[RepoTabId, MessageKey]>>(() => {
267 const middle = orderTabs(tabOptions?.order).filter((id) =>
268 id === 'pulls' ? (tabOptions?.allowPullRequests ?? true) : true,
269 )
270 const ids: RepoTabId[] = ['code', ...middle, ...(canAdmin ? (['settings'] as RepoTabId[]) : [])]
271 return ids.map((id) => [id, TAB_LABEL_KEYS.get(id) ?? 'shell.repo.tabs.code'])
272 }, [tabOptions, canAdmin])
273 const labelsKey = labels.map(([id]) => id).join(',')
274 const [fit, setFit] = useState(labels.length)
275 const [menuOpen, setMenuOpen] = useState(false)
276
277 const ids = labels.map(([id]) => id)
278 const activeIndex = Math.max(
279 0,
280 labels.findIndex(([id]) => tabGroup(active) === id),
281 )
282 const shown = fit >= ids.length ? ids : ids.slice(0, fit)
283 if (fit < ids.length && activeIndex >= fit) shown[fit - 1] = ids[activeIndex]
284 const hidden = labels.filter(([id]) => !shown.includes(id))
285 const overflow = menuOpen && hidden.length > 0
286
287 useLayoutEffect(() => {
288 if (!onChange) return
289 const strip = stripRef.current
290 const probe = probeRef.current
291 if (!strip || !probe) return
292 const host = strip.parentElement
293 const measure = () => {
294 const items = Array.from(probe.children) as HTMLElement[]
295 if (items.length === 0) return
296 const probeStyle = getComputedStyle(probe)
297 const gap = Number.parseFloat(probeStyle.columnGap) || 0
298 const pad = (Number.parseFloat(probeStyle.paddingLeft) || 0) + (Number.parseFloat(probeStyle.paddingRight) || 0)
299 // The pill hugs its labels so it never shows a trailing blank stretch,
300 // so its own width says nothing about the room available: the wrapper
301 // holding it takes the row's leftover width, and that leftover (its
302 // padding aside) is what the labels have to fit into.
303 const hostStyle = host ? getComputedStyle(host) : null
304 const hostPad = hostStyle
305 ? (Number.parseFloat(hostStyle.paddingLeft) || 0) + (Number.parseFloat(hostStyle.paddingRight) || 0)
306 : 0
307 const available = Math.max(0, (host ? host.clientWidth - hostPad : strip.clientWidth) - pad)
308 let used = 0
309 let count = 0
310 for (let index = 0; index < items.length; index += 1) {
311 const width = items[index].getBoundingClientRect().width
312 const need = width + (count > 0 ? gap : 0)
313 // While labels remain after this one the trigger has to fit beside them.
314 const reserve = index < items.length - 1 ? gap + MORE_WIDTH : 0
315 if (count > 0 && used + need + reserve > available) break
316 used += need
317 count += 1
318 }
319 const next = Math.max(1, count)
320 setFit((prev) => (prev === next ? prev : next))
321 }
322 measure()
323 // The labels fit into the wrapper's leftover width, so that wrapper is what
324 // has to be watched: the pill re-sizes itself as labels move into the menu
325 // and would otherwise only report its own previous answer.
326 const observer = new ResizeObserver(measure)
327 observer.observe(host ?? strip)
328 // Label widths follow the locale, and webfonts land after first paint.
329 document.fonts?.ready.then(measure).catch(() => {})
330 return () => observer.disconnect()
331 }, [onChange, locale, labelsKey])
332
333 useEffect(() => {
334 if (!menuOpen) return
335 const onPointerDown = (event: PointerEvent) => {
336 if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false)
337 }
338 const onKeyDown = (event: KeyboardEvent) => {
339 if (event.key !== 'Escape') return
340 setMenuOpen(false)
341 triggerRef.current?.focus()
342 }
343 document.addEventListener('pointerdown', onPointerDown)
344 document.addEventListener('keydown', onKeyDown)
345 return () => {
346 document.removeEventListener('pointerdown', onPointerDown)
347 document.removeEventListener('keydown', onKeyDown)
348 }
349 }, [menuOpen])
350
351 if (!onChange) {
352 return (
353 <nav className="flex h-9 w-fit items-center gap-1">
354 {labels.map(([id, key]) => {
355 const isActive = tabGroup(active) === id
356 return (
357 <span key={id} className={tabItemClass(isActive)}>
358 <TabContent id={id} labelKey={key} pullsCount={pullsCount} />
359 {isActive ? <TabIndicator /> : null}
360 </span>
361 )
362 })}
363 </nav>
364 )
365 }
366
367 return (
368 <nav ref={stripRef} className="relative flex h-9 w-fit items-center gap-1">
369 <nav
370 ref={probeRef}
371 aria-hidden="true"
372 className="pointer-events-none fixed -left-[9999px] top-0 flex items-center gap-1 opacity-0"
373 >
374 {labels.map(([id, key]) => (
375 <span key={id} className={`${TAB_ITEM_CLASS} text-shell-ink-muted`}>
376 <TabContent id={id} labelKey={key} pullsCount={pullsCount} />
377 </span>
378 ))}
379 </nav>
380 {labels
381 .filter(([id]) => shown.includes(id))
382 .map(([id, key]) => {
383 const isActive = tabGroup(active) === id
384 return (
385 <button
386 key={id}
387 type="button"
388 aria-current={isActive ? 'page' : undefined}
389 onClick={() => onChange(id)}
390 className={tabItemClass(isActive)}
391 >
392 <TabContent id={id} labelKey={key} pullsCount={pullsCount} />
393 {isActive ? <TabIndicator /> : null}
394 </button>
395 )
396 })}
397 {hidden.length > 0 ? (
398 <div ref={menuRef} className="relative ml-auto flex h-full items-center">
399 <button
400 ref={triggerRef}
401 type="button"
402 aria-haspopup="menu"
403 aria-expanded={overflow}
404 aria-label={t('shell.repo.tabs.more')}
405 title={t('shell.repo.tabs.more')}
406 onClick={() => setMenuOpen((open) => !open)}
407 className="repo-tabs-more"
408 >
409 <MoreHorizontal className="h-4 w-4" aria-hidden />
410 </button>
411 {overflow ? (
412 <div
413 role="menu"
414 aria-label={t('shell.repo.tabs.more')}
415 className="repo-tabs-menu absolute right-0 top-full z-40 mt-2 w-40 rounded-lg border border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
416 >
417 {hidden.map(([id, key]) => (
418 <button
419 key={id}
420 type="button"
421 role="menuitem"
422 onClick={() => {
423 onChange(id)
424 setMenuOpen(false)
425 }}
426 className="flex w-full items-center px-2.5 text-left text-[13px] font-medium text-shell-ink-muted transition-colors hover:text-shell-ink"
427 >
428 {t(key)}
429 </button>
430 ))}
431 </div>
432 ) : null}
433 </div>
434 ) : null}
435 </nav>
436 )
437}
438
439function FilesPlaceholder() {
440 const rows: Array<{ icon: 'folder' | 'file'; name: number; msg: number }> = [
441 { icon: 'folder', name: 88, msg: 260 },
442 { icon: 'folder', name: 64, msg: 320 },
443 { icon: 'file', name: 96, msg: 220 },
444 { icon: 'file', name: 120, msg: 280 },
445 { icon: 'file', name: 72, msg: 240 },
446 { icon: 'file', name: 104, msg: 200 },
447 ]
448 return (
449 <div className="divide-y divide-shell-hairline rounded-lg border border-shell-hairline bg-shell-surface">
450 {rows.map((row, i) => (
451 <div key={i} className="flex h-11 items-center gap-3 px-4">
452 {row.icon === 'folder' ? (
453 <Folder className="h-4 w-4 shrink-0 text-shell-ink-muted" />
454 ) : (
455 <File className="h-4 w-4 shrink-0 text-shell-ink-muted" />
456 )}
457 <span className="h-3.5 rounded-full bg-shell-active" style={{ width: row.name }} />
458 <span className="ml-auto hidden h-3 rounded-full bg-shell-field md:block" style={{ width: row.msg }} />
459 <span className="h-3 w-14 shrink-0 rounded-full bg-shell-field" />
460 </div>
461 ))}
462 </div>
463 )
464}
465
466function RepoAsidePlaceholder() {
467 return (
468 <>
469 <div className="space-y-2">
470 <div className="h-4 w-14 rounded-full bg-shell-active" />
471 <div className="h-3 rounded-full bg-shell-field" />
472 <div className="h-3 w-4/5 rounded-full bg-shell-field" />
473 </div>
474 <div className="space-y-2">
475 <div className="h-4 w-14 rounded-full bg-shell-active" />
476 <div className="flex h-2 overflow-hidden rounded-full">
477 <span className="w-[55%] bg-shell-tile" />
478 <span className="w-[25%] bg-[#eb459e]" />
479 <span className="w-[12%] bg-[#f0b232]" />
480 <span className="w-[8%] bg-[#3ba55d]" />
481 </div>
482 </div>
483 <div className="space-y-2">
484 <div className="h-4 w-20 rounded-full bg-shell-active" />
485 <div className="h-8 rounded-md bg-shell-field" />
486 <div className="h-8 rounded-md bg-shell-field" />
487 </div>
488 <div className="flex gap-1.5">
489 {['#66717d', '#ca6a8f', '#bd923d', '#4f9874', '#8b8f99'].map((c) => (
490 <span key={c} className="h-8 w-8 rounded-full" style={{ backgroundColor: c }} />
491 ))}
492 </div>
493 </>
494 )
495}
Added view/app/src/pages/workspace/ui/repo/clone-menu.tsx view/app/src/pages/workspace/ui/repo/clone-menu.tsx +168 −0 View view/app/src/pages/workspace/ui/repo/clone-menu.tsx
@@ -0,0 +1,168 @@
1import { type ArchiveFormat, type CloneTarget, createArchive } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { Check, ChevronDown, Code, Copy, Loader2 } from 'lucide-react'
4import { useEffect, useRef, useState } from 'react'
5
6const ARCHIVE_FORMATS: Array<{ value: ArchiveFormat; label: string }> = [
7 { value: 'zip', label: 'zip' },
8 { value: 'tar_gz', label: 'tar.gz' },
9 { value: 'tar_bz2', label: 'tar.bz2' },
10 { value: 'tar', label: 'tar' },
11]
12
13/**
14 * Toolbar "Clone" chip: opens a panel with the backend-provided SSH / HTTPS
15 * addresses, each row copying its own URL.
16 */
17export function RepoCloneMenu({
18 targets,
19 space,
20 repo,
21 rev,
22}: {
23 targets: CloneTarget[]
24 space: string
25 repo: string
26 rev: string
27}) {
28 const { t } = useT()
29 const [open, setOpen] = useState(false)
30 const [copied, setCopied] = useState<CloneTarget['protocol'] | null>(null)
31 const [busy, setBusy] = useState<ArchiveFormat | null>(null)
32 const [downloadFailed, setDownloadFailed] = useState(false)
33 const root = useRef<HTMLDivElement>(null)
34 const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
35 // SSH first, matching the panel the reference UI shows.
36 const ordered = [...targets].sort((a, b) => Number(b.protocol === 'ssh') - Number(a.protocol === 'ssh'))
37
38 useEffect(() => () => clearTimeout(copyTimer.current), [])
39
40 useEffect(() => {
41 if (!open) return
42 const onDown = (event: PointerEvent) => {
43 if (!root.current?.contains(event.target as Node)) setOpen(false)
44 }
45 const onKey = (event: KeyboardEvent) => {
46 if (event.key === 'Escape') setOpen(false)
47 }
48 window.addEventListener('pointerdown', onDown)
49 window.addEventListener('keydown', onKey)
50 return () => {
51 window.removeEventListener('pointerdown', onDown)
52 window.removeEventListener('keydown', onKey)
53 }
54 }, [open])
55
56 async function copy(target: CloneTarget) {
57 try {
58 await navigator.clipboard.writeText(target.url)
59 } catch {
60 // Clipboard access can be denied; the label still confirms the attempt.
61 }
62 setCopied(target.protocol)
63 clearTimeout(copyTimer.current)
64 copyTimer.current = setTimeout(() => setCopied(null), 1500)
65 }
66
67 /** Archives are built on demand; the short-lived link is clicked straight away. */
68 async function download(format: ArchiveFormat) {
69 if (busy || !rev) return
70 setBusy(format)
71 setDownloadFailed(false)
72 try {
73 const result = await createArchive(space, repo, { revision: rev, format })
74 if (!result.download_url) throw new Error('archive url missing')
75 const link = document.createElement('a')
76 link.href = result.download_url
77 link.rel = 'noopener'
78 link.click()
79 } catch {
80 setDownloadFailed(true)
81 } finally {
82 setBusy(null)
83 }
84 }
85
86 return (
87 <div ref={root} className="relative flex items-center">
88 <button
89 type="button"
90 aria-haspopup="dialog"
91 aria-expanded={open}
92 aria-label={t('repo.clone.title')}
93 onClick={() => setOpen((value) => !value)}
94 className="flex h-9 shrink-0 items-center gap-1.5 rounded-[9px] bg-[#1f883d] px-2.5 text-[13px] font-medium text-white transition-colors hover:brightness-110"
95 >
96 <Code className="h-3.5 w-3.5 shrink-0" aria-hidden />
97 <span className="whitespace-nowrap">{t('repo.clone.button')}</span>
98 <ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
99 </button>
100 {open ? (
101 <div
102 role="dialog"
103 aria-label={t('repo.clone.title')}
104 className="absolute right-0 top-full z-40 mt-2 w-[420px] max-w-[calc(100vw-2rem)] rounded-lg border border-shell-hairline bg-shell-surface p-3 shadow-xl"
105 >
106 {ordered.length === 0 ? (
107 <p className="text-[12.5px] text-shell-ink-muted" title={t('repo.clone.notConfiguredHint')}>
108 {t('repo.clone.notConfigured')}
109 </p>
110 ) : (
111 <div className="space-y-3">
112 {ordered.map((target) => (
113 <div key={target.protocol}>
114 <p className="mb-1.5 text-[12.5px] font-semibold">
115 {target.protocol === 'ssh' ? t('repo.clone.withSsh') : t('repo.clone.withHttps')}
116 </p>
117 <div className="flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 py-1.5">
118 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={target.url}>
119 {target.url}
120 </span>
121 <button
122 type="button"
123 aria-label={copied === target.protocol ? t('repo.clone.copiedLabel') : t('repo.clone.copy')}
124 title={target.protocol === 'ssh' ? t('repo.clone.sshHint') : t('repo.clone.copy')}
125 onClick={() => void copy(target)}
126 className="flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-[12px] transition-colors hover:bg-shell-active hover:text-shell-ink"
127 >
128 {copied === target.protocol ? (
129 <Check className="h-3.5 w-3.5" aria-hidden />
130 ) : (
131 <Copy className="h-3.5 w-3.5" aria-hidden />
132 )}
133 {copied === target.protocol ? t('common.action.copied') : t('common.action.copy')}
134 </button>
135 </div>
136 </div>
137 ))}
138 </div>
139 )}
140 <div className="mt-3 border-t border-shell-hairline pt-3">
141 <p className="mb-1.5 text-[12.5px] font-semibold">{t('repo.clone.download')}</p>
142 <div className="flex overflow-hidden rounded-lg border border-shell-hairline">
143 {ARCHIVE_FORMATS.map((format, index) => (
144 <button
145 key={format.value}
146 type="button"
147 disabled={busy !== null || !rev}
148 onClick={() => void download(format.value)}
149 className={`flex h-8 flex-1 items-center justify-center gap-1 text-[12.5px] font-medium transition-colors hover:bg-shell-active disabled:opacity-60 ${
150 index > 0 ? 'border-l border-shell-hairline' : ''
151 }`}
152 >
153 {busy === format.value ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
154 {format.label}
155 </button>
156 ))}
157 </div>
158 {downloadFailed ? (
159 <p className="mt-1.5 text-[12px] text-shell-ink-muted" role="alert">
160 {t('repo.clone.downloadFailed')}
161 </p>
162 ) : null}
163 </div>
164 </div>
165 ) : null}
166 </div>
167 )
168}
Added view/app/src/pages/workspace/ui/repo/ref-picker.tsx view/app/src/pages/workspace/ui/repo/ref-picker.tsx +224 −0 View view/app/src/pages/workspace/ui/repo/ref-picker.tsx
@@ -0,0 +1,224 @@
1import { listBranches, listTags } from '@app/domains/repo/model'
2import { useT } from '@app/i18n'
3import { repoKey, shortSha, useAsync } from '@app/lib'
4import { Check, ChevronDown, GitBranch, GitCommitHorizontal, Search, Tag } from 'lucide-react'
5import { useEffect, useId, useRef, useState } from 'react'
6
7export interface RefPickerProps {
8 space: string
9 repo: string
10 rev: string
11 onRevChange: (rev: string) => void
12 /** Totals from RepoDto — the dropdowns themselves are capped per page. */
13 branchesCount: number
14 tagsCount: number
15 commitsCount: number
16 /** Jump to the commit-history tab. */
17 onShowCommits: () => void
18}
19
20/** Ref chooser for the repo toolbar (JSON API, session auth): the active
21 * revision plus branch / tag / commit counts, each opening its own surface. */
22export function RefPicker({
23 space,
24 repo,
25 rev,
26 onRevChange,
27 branchesCount,
28 tagsCount,
29 commitsCount,
30 onShowCommits,
31}: RefPickerProps) {
32 const { t } = useT()
33 const branches = useAsync(() => listBranches(space, repo).catch(() => null), [space, repo], {
34 cacheKey: repoKey(space, repo, 'refs:branches:100'),
35 })
36 const tags = useAsync(() => listTags(space, repo).catch(() => null), [space, repo], {
37 cacheKey: repoKey(space, repo, 'refs:tags:100'),
38 })
39 const [open, setOpen] = useState<null | 'branch' | 'tag'>(null)
40 const [query, setQuery] = useState('')
41 const rootRef = useRef<HTMLDivElement>(null)
42 const triggerRef = useRef<HTMLButtonElement | null>(null)
43 const popupId = useId()
44
45 const closePicker = () => {
46 setOpen(null)
47 triggerRef.current?.focus()
48 }
49
50 useEffect(() => {
51 if (!open) return
52 const onDown = (event: PointerEvent) => {
53 if (!rootRef.current?.contains(event.target as Node)) setOpen(null)
54 }
55 const onKey = (event: KeyboardEvent) => {
56 if (event.key === 'Escape') {
57 setOpen(null)
58 triggerRef.current?.focus()
59 }
60 }
61 window.addEventListener('pointerdown', onDown)
62 window.addEventListener('keydown', onKey)
63 return () => {
64 window.removeEventListener('pointerdown', onDown)
65 window.removeEventListener('keydown', onKey)
66 }
67 }, [open])
68
69 const branchNames = (branches.data?.items ?? []).map((b) => b.name)
70 const tagNames = (tags.data?.items ?? []).map((t) => t.name)
71 // The list endpoint is the truth; RepoDto counters can lag (imported repos).
72 const branchTotal = branches.data?.total ?? branchesCount
73 const tagTotal = tags.data?.total ?? tagsCount
74 const isBranch = branchNames.includes(rev)
75 const isTag = tagNames.includes(rev)
76 // A raw sha is a valid revision too — label it so the chip never claims a
77 // commit is a tag.
78 const revLabel = !rev
79 ? t('repo.refs.selectBranch')
80 : isBranch || isTag || branches.loading
81 ? rev
82 : t('repo.refs.commit', { sha: shortSha(rev, 8) })
83 const items = (open === 'branch' ? branchNames : tagNames).filter((name) =>
84 name.toLowerCase().includes(query.toLowerCase()),
85 )
86
87 const toggle = (kind: 'branch' | 'tag', trigger: HTMLButtonElement) => {
88 triggerRef.current = trigger
89 setQuery('')
90 setOpen((current) => (current === kind ? null : kind))
91 }
92
93 return (
94 <div ref={rootRef} className="repo-refs relative flex min-w-0 flex-wrap items-center gap-2">
95 <button
96 type="button"
97 aria-haspopup="dialog"
98 aria-controls={open === 'branch' ? popupId : undefined}
99 aria-label={t('repo.refs.current', { rev: revLabel })}
100 aria-expanded={open === 'branch'}
101 onClick={(event) => toggle('branch', event.currentTarget)}
102 className="repo-refs-trigger flex h-9 max-w-[220px] items-center gap-1.5 rounded-md bg-shell-field px-2.5 text-[13px] font-medium transition-colors hover:brightness-95"
103 >
104 <GitBranch className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
105 <span className="truncate">{revLabel}</span>
106 <ChevronDown className="h-3 w-3 shrink-0 text-shell-ink-muted" aria-hidden />
107 </button>
108 <span className="repo-refs-divider" aria-hidden />
109 <button
110 type="button"
111 aria-haspopup="dialog"
112 aria-controls={open === 'branch' ? popupId : undefined}
113 aria-expanded={open === 'branch'}
114 onClick={(event) => toggle('branch', event.currentTarget)}
115 className="repo-refs-count"
116 >
117 <GitBranch className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
118 <span className="whitespace-nowrap">{t('repo.refs.branchLabel')}</span>
119 <span className="rounded-full bg-shell-active px-1.5 text-[11px] font-semibold leading-[18px] text-shell-ink-muted">
120 {branchTotal}
121 </span>
122 </button>
123 <button
124 type="button"
125 aria-haspopup="dialog"
126 aria-controls={open === 'tag' ? popupId : undefined}
127 aria-expanded={open === 'tag'}
128 onClick={(event) => toggle('tag', event.currentTarget)}
129 className="repo-refs-count"
130 >
131 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
132 <span className="whitespace-nowrap">{t('repo.refs.tagLabel')}</span>
133 <span className="rounded-full bg-shell-active px-1.5 text-[11px] font-semibold leading-[18px] text-shell-ink-muted">
134 {tagTotal}
135 </span>
136 </button>
137 <button type="button" onClick={onShowCommits} className="repo-refs-count">
138 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
139 <span className="whitespace-nowrap">{t('repo.refs.commitLabel')}</span>
140 <span className="rounded-full bg-shell-active px-1.5 text-[11px] font-semibold leading-[18px] text-shell-ink-muted">
141 {commitsCount}
142 </span>
143 </button>
144 {open ? (
145 <div
146 id={popupId}
147 role="dialog"
148 aria-label={open === 'branch' ? t('repo.branch.switch') : t('repo.tag.switch')}
149 className="repo-ref-popover absolute left-0 top-full z-40 mt-2 w-72 rounded-lg border border-shell-hairline bg-shell-surface p-2 shadow-xl"
150 >
151 <p className="px-2.5 pb-2 pt-1 text-[13px] font-semibold">
152 {open === 'branch' ? t('repo.branch.switch') : t('repo.tag.switch')}
153 </p>
154 <div className="repo-ref-search flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 py-1.5">
155 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
156 <input
157 autoFocus
158 aria-label={open === 'branch' ? t('repo.refs.searchBranches') : t('repo.refs.searchTags')}
159 value={query}
160 onChange={(e) => setQuery(e.target.value)}
161 onKeyDown={(event) => {
162 if (event.key === 'ArrowDown') {
163 event.preventDefault()
164 rootRef.current?.querySelector<HTMLButtonElement>('[role="option"]')?.focus()
165 }
166 }}
167 placeholder={open === 'branch' ? t('repo.branches.filterPlaceholder') : t('repo.tags.filterPlaceholder')}
168 className="w-full min-w-0 bg-transparent text-[13px] leading-6 outline-none"
169 />
170 </div>
171 <div
172 role="listbox"
173 aria-label={open === 'branch' ? t('repo.branches.title') : t('repo.tags.title')}
174 className="max-h-60 overflow-y-auto pt-1"
175 >
176 {items.length === 0 ? (
177 <p className="px-2.5 py-3 text-center text-[12.5px] text-shell-ink-muted">
178 {query ? t('repo.refs.noMatch') : open === 'branch' ? t('repo.refs.noBranches') : t('repo.refs.noTags')}
179 </p>
180 ) : null}
181 {items.map((name) => (
182 <button
183 key={name}
184 type="button"
185 role="option"
186 aria-selected={name === rev}
187 onClick={() => {
188 onRevChange(name)
189 closePicker()
190 }}
191 onKeyDown={(event) => {
192 if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
193 event.preventDefault()
194 const options = Array.from(
195 rootRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [],
196 )
197 const current = options.indexOf(event.currentTarget)
198 const next =
199 event.key === 'Home'
200 ? 0
201 : event.key === 'End'
202 ? options.length - 1
203 : (current + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length
204 options[next]?.focus()
205 }}
206 className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors hover:bg-shell-active ${
207 name === rev ? 'font-semibold' : ''
208 }`}
209 >
210 {open === 'branch' ? (
211 <GitBranch className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
212 ) : (
213 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
214 )}
215 <span className="truncate">{name}</span>
216 {name === rev ? <Check className="ml-auto h-4 w-4 shrink-0 text-shell-ink" aria-hidden /> : null}
217 </button>
218 ))}
219 </div>
220 </div>
221 ) : null}
222 </div>
223 )
224}
Added view/app/src/pages/workspace/ui/repo/repo-aside.tsx view/app/src/pages/workspace/ui/repo/repo-aside.tsx +234 −0 View view/app/src/pages/workspace/ui/repo/repo-aside.tsx
@@ -0,0 +1,234 @@
1import {
2 type CommitRow,
3 htmlFragmentUrl,
4 listCommitsPage,
5 listReleases,
6 type RepoDetail,
7 setRepoWatch,
8 starRepo,
9 unstarRepo,
10 useHtmlFragment,
11} from '@app/domains/repo/model'
12import { InitialAvatar } from '@app/domains/shared'
13import { useT } from '@app/i18n'
14import { fmtRelative, repoKey, shortSha, useAsync } from '@app/lib'
15import { ExternalLink, Eye, GitCommitHorizontal, Scale, Star, Tag, Users } from 'lucide-react'
16import { useState } from 'react'
17
18export interface RepoAsideProps {
19 space: string
20 repoName: string
21 repo: RepoDetail
22 mode: 'html' | 'json'
23 rev: string
24 onChanged: () => void
25 onOpenCommits: () => void
26 onOpenReleases: () => void
27}
28
29/** Distinct authors of the recent window, newest first, capped for the stack. */
30function uniqueAuthors(commits: CommitRow[]): string[] {
31 const seen = new Map<string, string>()
32 for (const commit of commits) {
33 const key = commit.author_name || commit.author_email
34 if (!key || seen.has(key)) continue
35 seen.set(key, commit.author_name || key)
36 if (seen.size >= 5) break
37 }
38 return [...seen.values()]
39}
40
41export function RepoAside({
42 space,
43 repoName,
44 repo,
45 mode,
46 rev,
47 onChanged,
48 onOpenCommits,
49 onOpenReleases,
50}: RepoAsideProps) {
51 const { t } = useT()
52 const [starBusy, setStarBusy] = useState(false)
53 const [starError, setStarError] = useState<string | null>(null)
54 const [watchBusy, setWatchBusy] = useState(false)
55 const [watchError, setWatchError] = useState<string | null>(null)
56 const starred = repo.me?.starred ?? false
57 const watching = repo.me?.watch_mode === 'watching'
58 const website = repo.website
59 const headRev = rev || repo.default_branch
60
61 // Decision-useful facts only: what shipped last, what moved last, who is
62 // behind it. Branch/tag/commit counts already live in the toolbar.
63 const releases = useAsync(() => listReleases(space, repoName, { limit: 10 }).catch(() => null), [space, repoName], {
64 cacheKey: repoKey(space, repoName, 'aside:releases'),
65 })
66 const commits = useAsync(
67 () => listCommitsPage(space, repoName, { revision: headRev, skip: 0, limit: 30 }).catch(() => null),
68 [space, repoName, headRev],
69 { cacheKey: repoKey(space, repoName, `aside:commits:${headRev}`) },
70 )
71 const latestRelease = (releases.data?.items ?? [])
72 .filter((item) => !item.is_draft)
73 .sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
74 const latestCommit = commits.data?.commits?.[0]
75 const authors = uniqueAuthors(commits.data?.commits ?? [])
76
77 const languagesFragment = useHtmlFragment(
78 mode === 'html' && repo.commits_count > 0 && rev
79 ? htmlFragmentUrl(space, repoName, 'languages', { revision: rev })
80 : null,
81 )
82 const languagesHtml = languagesFragment?.ok ? languagesFragment.html : null
83
84 async function toggleStar() {
85 if (starBusy) return
86 setStarBusy(true)
87 setStarError(null)
88 try {
89 if (starred) await unstarRepo(space, repoName)
90 else await starRepo(space, repoName)
91 onChanged()
92 } catch {
93 setStarError(t('shell.repo.starFailed'))
94 } finally {
95 setStarBusy(false)
96 }
97 }
98
99 async function toggleWatch() {
100 if (watchBusy) return
101 setWatchBusy(true)
102 setWatchError(null)
103 try {
104 await setRepoWatch(space, repoName, watching ? 'unwatched' : 'watching')
105 onChanged()
106 } catch {
107 setWatchError(t('shell.repo.watchFailed'))
108 } finally {
109 setWatchBusy(false)
110 }
111 }
112
113 return (
114 <div className="repo-overview space-y-4">
115 <section className="repo-about-card rounded-xl bg-shell-surface p-5">
116 <h3 className="mb-3 text-[14px] font-semibold">{t('shell.repo.overview')}</h3>
117 <p className="mb-4 break-words text-[13px] leading-6 text-shell-ink-muted">
118 {repo.description || t('shell.repo.descriptionFallback')}
119 </p>
120 {website ? (
121 <button
122 type="button"
123 title={t('shell.repo.openWebsite', { url: website })}
124 onClick={() => window.open(website, '_blank', 'noopener,noreferrer')}
125 className="mb-4 flex w-full items-start gap-1.5 break-all text-left text-[12.5px] text-shell-ink-muted underline decoration-shell-hairline underline-offset-4 hover:text-shell-ink hover:decoration-shell-ink-muted"
126 >
127 <ExternalLink className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
128 {website}
129 </button>
130 ) : null}
131 <dl className="repo-overview-facts border-t border-shell-hairline pt-3 text-[12.5px]">
132 <div className="repo-fact">
133 <Tag className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
134 <dt className="text-shell-ink-muted">{t('shell.repo.latestRelease')}</dt>
135 <dd className="ml-auto min-w-0">
136 {latestRelease ? (
137 <button
138 type="button"
139 onClick={onOpenReleases}
140 title={latestRelease.title || latestRelease.tag_name}
141 className="repo-fact-value"
142 >
143 <span className="font-mono">{latestRelease.tag_name}</span>
144 <span className="text-shell-ink-muted"> · {fmtRelative(latestRelease.created_at)}</span>
145 </button>
146 ) : (
147 <span className="text-shell-ink-muted">{t('shell.repo.noRelease')}</span>
148 )}
149 </dd>
150 </div>
151 <div className="repo-fact">
152 <GitCommitHorizontal className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
153 <dt className="text-shell-ink-muted">{t('shell.repo.latestCommit')}</dt>
154 <dd className="ml-auto min-w-0">
155 {latestCommit ? (
156 <button type="button" onClick={onOpenCommits} title={latestCommit.message} className="repo-fact-value">
157 <span className="font-mono">{shortSha(latestCommit.id)}</span>
158 <span className="text-shell-ink-muted"> · {fmtRelative(latestCommit.author_time)}</span>
159 </button>
160 ) : (
161 <span className="text-shell-ink-muted">—</span>
162 )}
163 </dd>
164 </div>
165 {authors.length ? (
166 <div className="repo-fact">
167 <Users className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
168 <dt className="text-shell-ink-muted">{t('shell.repo.contributors')}</dt>
169 <dd className="ml-auto flex items-center -space-x-1">
170 {authors.map((name) => (
171 <InitialAvatar key={name} name={name} size={18} />
172 ))}
173 </dd>
174 </div>
175 ) : null}
176 {repo.license_name ? (
177 <div className="repo-fact">
178 <Scale className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
179 <dt className="text-shell-ink-muted">{t('shell.repo.license')}</dt>
180 <dd className="ml-auto min-w-0 truncate text-shell-ink" title={repo.license_name}>
181 {repo.license_name}
182 </dd>
183 </div>
184 ) : null}
185 </dl>
186 <div className="mt-4 grid grid-cols-2 gap-2">
187 <button
188 type="button"
189 disabled={starBusy}
190 aria-pressed={starred}
191 onClick={toggleStar}
192 className={`repo-action-button ${starred ? 'is-on' : ''}`}
193 >
194 <Star className="h-3.5 w-3.5" fill={starred ? 'currentColor' : 'none'} aria-hidden />
195 {starBusy
196 ? t('shell.repo.starUpdating')
197 : starred
198 ? t('shell.repo.starred', { count: repo.stars_count })
199 : t('shell.repo.star', { count: repo.stars_count })}
200 </button>
201 <button
202 type="button"
203 disabled={watchBusy}
204 aria-pressed={watching}
205 onClick={toggleWatch}
206 className={`repo-action-button ${watching ? 'is-on' : ''}`}
207 >
208 <Eye className="h-3.5 w-3.5" aria-hidden />
209 {watchBusy
210 ? t('shell.repo.starUpdating')
211 : watching
212 ? t('shell.repo.watching', { count: repo.watchers_count })
213 : t('shell.repo.watch', { count: repo.watchers_count })}
214 </button>
215 </div>
216 {starError || watchError ? (
217 <p className="mt-2 text-[12px] text-shell-ink-muted" role="alert">
218 {starError ?? watchError}
219 </p>
220 ) : null}
221 </section>
222 {languagesHtml ? (
223 <section className="rounded-xl bg-shell-surface p-5">
224 <h3 className="mb-3 text-[14px] font-semibold">{t('shell.repo.languages')}</h3>
225 <div className="repo-embed" dangerouslySetInnerHTML={{ __html: languagesHtml }} />
226 </section>
227 ) : repo.primary_language ? (
228 <section className="rounded-xl bg-shell-surface p-5 text-[13px]">
229 {t('shell.repo.primaryLanguage', { language: repo.primary_language ?? '' })}
230 </section>
231 ) : null}
232 </div>
233 )
234}
Added view/app/src/pages/workspace/ui/repo/repo-polish.css view/app/src/pages/workspace/ui/repo/repo-polish.css +574 −0 View view/app/src/pages/workspace/ui/repo/repo-polish.css
@@ -0,0 +1,574 @@
1.repo-view-content {
2 min-height: 0;
3 padding-top: 4px;
4}
5.repo-refs-row {
6 display: flex;
7 flex-wrap: wrap;
8 align-items: center;
9 gap: 8px;
10 padding: 7px 12px;
11 border-bottom: 1px solid var(--shell-hairline);
12 background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface));
13}
14.repo-refs-row > .repo-refs {
15 min-width: 0;
16 flex: 1 1 auto;
17}
18.repo-refs-row > .repo-refs-clone {
19 display: flex;
20 flex: none;
21 align-items: center;
22 margin-left: auto;
23}
24.repo-refs-trigger {
25 min-height: 36px;
26 border: 1px solid var(--shell-hairline);
27 border-radius: 9px;
28 background: var(--shell-field);
29 font-size: 13px;
30 transition:
31 color 160ms,
32 border-color 160ms,
33 background 160ms;
34}
35.repo-refs-trigger:hover,
36.repo-refs-trigger[aria-expanded="true"] {
37 border-color: var(--shell-tile);
38 color: var(--shell-ink-muted);
39 background: var(--shell-field);
40}
41.repo-refs-divider {
42 width: 1px;
43 height: 20px;
44 flex: none;
45 margin-inline: 2px;
46 background: var(--shell-hairline);
47}
48.repo-refs-count {
49 display: flex;
50 height: 36px;
51 flex: none;
52 align-items: center;
53 gap: 6px;
54 border-radius: 8px;
55 padding-inline: 8px;
56 font-size: 13px;
57 font-weight: 500;
58 color: var(--shell-ink);
59 transition: background 160ms;
60}
61.repo-refs-count:hover {
62 background: var(--shell-field);
63}
64.repo-toolbar {
65 background: var(--shell-surface);
66}
67.repo-toolbar-tabs {
68 order: 1;
69 padding-left: 0;
70 border-left: 0;
71}
72.repo-toolbar-clone {
73 order: 2;
74}
75.repo-toolbar-refs {
76 order: 3;
77 flex: 1 1 100%;
78}
79.repo-toolbar-refs > span {
80 min-height: 36px;
81 border: 1px solid var(--shell-hairline);
82 border-radius: 9px;
83 background: var(--shell-field);
84}
85.repo-toolbar-tabs button,
86.repo-toolbar-tabs span {
87 transition:
88 color 160ms,
89 background 160ms;
90}
91
92.repo-toolbar .repo-toolbar-tabs {
93 flex: 1 1 20rem;
94 min-width: 20rem;
95 max-width: calc(100% - 3rem);
96}
97.repo-toolbar-tabs > nav {
98 flex: 0 0 auto;
99}
100.repo-toolbar .repo-toolbar-tabs {
101 overflow: visible;
102}
103.repo-tabs-more {
104 display: flex;
105 height: 100%;
106 width: 1.75rem;
107 flex-shrink: 0;
108 align-items: center;
109 justify-content: center;
110 border-radius: 8px;
111 color: var(--shell-ink-muted);
112 transition:
113 color 160ms,
114 background 160ms;
115}
116.repo-tabs-more:hover,
117.repo-tabs-more[aria-expanded="true"] {
118 color: var(--shell-ink);
119 background: var(--shell-field);
120}
121.repo-tabs-menu button {
122 min-height: 32px;
123 border-radius: 7px;
124}
125.repo-tabs-menu button:hover {
126 background: var(--shell-field);
127}
128.repo-toolbar .repo-toolbar-clone.empty {
129 display: flex;
130 flex: 0 0 auto;
131 margin-left: auto;
132}
133@media (max-width: 1200px) {
134 .repo-toolbar .repo-toolbar-refs {
135 flex: 1 1 100%;
136 }
137}
138@media (max-width: 480px) {
139 .repo-toolbar .repo-toolbar-tabs {
140 min-width: 12rem;
141 }
142}
143.repo-content-layout [data-repo-scroll] {
144 padding-right: 2px;
145}
146.repo-section-toolbar {
147 min-height: 42px;
148}
149.repo-section-toolbar h3 {
150 letter-spacing: -0.01em;
151}
152.repo-file-toolbar {
153 min-height: 44px;
154 padding: 10px 12px;
155 border: 1px solid var(--shell-hairline);
156 border-radius: 12px;
157 background: var(--shell-surface);
158}
159.repo-code-view {
160 display: flex;
161 flex-direction: column;
162 gap: 12px;
163}
164.repo-code-header {
165 padding: 6px 8px;
166 border-radius: 12px;
167 background: var(--shell-surface);
168 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
169}
170.repo-code-header-path {
171 min-width: 0;
172 flex: 1 1 110px;
173 overflow: hidden;
174 white-space: nowrap;
175}
176.repo-code-header-path.repo-breadcrumbs {
177 flex-wrap: nowrap;
178}
179.repo-code-header-path button {
180 white-space: nowrap;
181}
182.repo-directory-header {
183 padding: 9px 12px;
184 border-bottom: 1px solid var(--shell-hairline);
185 background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface));
186}
187.repo-tree-card .repo-embed {
188 border-radius: 0;
189}
190/* 文件树与卡片齐平:表格自身的边框/圆角会和卡片边框叠成双层,去掉后表头
191 底色可以铺满整个宽度,左右不再留 12px 的外边距。 */
192.repo-tree-card .repo-embed [data-component="git-tree"] {
193 border: 0;
194 border-radius: 0;
195}
196/* 最后一行的分隔线贴着卡片下边框,会把一条线画成两条:留下卡片那条就够。 */
197.repo-tree-card .repo-embed [data-component="git-tree"] tbody tr:last-child {
198 border-bottom: 0;
199}
200.repo-breadcrumbs button {
201 border-radius: 6px;
202 padding: 3px 5px;
203 transition:
204 color 160ms,
205 background 160ms;
206}
207.repo-breadcrumbs button:hover {
208 color: var(--shell-ink);
209 background: var(--shell-field);
210}
211.repo-tree-card {
212 display: flex;
213 flex-direction: column;
214 gap: 2px;
215 overflow: hidden;
216 border: 1px solid var(--shell-hairline);
217 border-radius: 14px;
218 background: var(--shell-surface);
219 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
220}
221.repo-file-table th {
222 padding: 12px 16px;
223 background: var(--shell-field);
224 font-size: 11px;
225}
226.repo-file-table td {
227 padding: 11px 16px;
228}
229.repo-file-table tbody tr {
230 transition: background 150ms;
231}
232.repo-file-table tbody tr:hover {
233 background: var(--shell-field);
234}
235.repo-file-table button {
236 border-radius: 6px;
237 color: var(--shell-ink);
238}
239.repo-file-table button:hover {
240 color: var(--shell-ink);
241 text-decoration: underline;
242 text-underline-offset: 3px;
243}
244.repo-readme,
245.repo-commit-summary,
246.repo-diff-card,
247.repo-settings-content,
248.repo-about-card {
249 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
250}
251.repo-pdf-toolbar {
252 display: flex;
253 align-items: center;
254 gap: 4px;
255 padding: 6px 8px;
256 border: 1px solid var(--shell-hairline);
257 border-bottom: 0;
258 border-radius: 12px 12px 0 0;
259 background: var(--shell-surface);
260}
261.repo-pdf-step {
262 display: flex;
263 height: 30px;
264 min-width: 30px;
265 align-items: center;
266 justify-content: center;
267 border-radius: 7px;
268 color: var(--shell-ink-muted);
269 transition:
270 color 160ms,
271 background 160ms;
272}
273.repo-pdf-step:hover:not(:disabled) {
274 color: var(--shell-ink);
275 background: var(--shell-field);
276}
277.repo-pdf-step:disabled {
278 opacity: 40%;
279}
280.repo-pdf-count {
281 padding: 0 4px;
282 font-size: 12.5px;
283 font-variant-numeric: tabular-nums;
284 color: var(--shell-ink-muted);
285}
286.repo-pdf-zoom {
287 display: flex;
288 margin-left: auto;
289 align-items: center;
290 gap: 2px;
291}
292.repo-pdf-scroll {
293 display: flex;
294 height: min(78vh, 900px);
295 min-height: 420px;
296 flex-direction: column;
297 gap: 12px;
298 overflow: auto;
299 padding: 12px;
300 border: 1px solid var(--shell-hairline);
301 border-radius: 0 0 12px 12px;
302 background: color-mix(in srgb, var(--shell-field) 55%, var(--shell-surface));
303}
304.repo-pdf-page {
305 display: flex;
306 justify-content: center;
307}
308.repo-pdf-page img {
309 max-width: 100%;
310 height: auto;
311 border-radius: 3px;
312 background: #fff;
313 box-shadow: 0 1px 3px rgb(24 28 31 / 18%);
314}
315.repo-pdf-note {
316 padding: 6px 0;
317 text-align: center;
318 font-size: 12.5px;
319 color: var(--shell-ink-muted);
320}
321.repo-ref-popover {
322 border-radius: 12px;
323 box-shadow: 0 16px 44px rgb(24 28 31 / 16%);
324}
325.repo-ref-search {
326 transition:
327 border-color 160ms,
328 background 160ms,
329 box-shadow 160ms;
330}
331.repo-ref-search:hover {
332 border-color: var(--shell-tile);
333}
334.repo-ref-search:focus-within {
335 border-color: var(--shell-tile);
336 background: var(--shell-surface);
337 box-shadow: 0 0 0 3px color-mix(in srgb, var(--shell-focus) 20%, transparent);
338}
339.repo-ref-popover input {
340 min-height: 30px;
341 font-size: 13.5px;
342}
343.repo-ref-popover [role="option"] {
344 min-height: 36px;
345 border-radius: 8px;
346}
347.repo-ref-popover [role="option"]:hover,
348.repo-ref-popover [aria-selected="true"] {
349 background: var(--shell-field);
350}
351.repo-about-card {
352 border-radius: 14px;
353}
354.repo-overview-facts {
355 display: flex;
356 flex-direction: column;
357 gap: 2px;
358}
359.repo-fact {
360 display: flex;
361 min-height: 30px;
362 align-items: center;
363 gap: 8px;
364}
365.repo-fact dt {
366 white-space: nowrap;
367}
368.repo-fact-value {
369 margin-right: -7px;
370 border-radius: 7px;
371 padding: 4px 7px;
372 color: var(--shell-ink);
373 transition: background 150ms;
374}
375.repo-fact-value:hover {
376 background: var(--shell-field);
377}
378.repo-action-button {
379 display: flex;
380 height: 36px;
381 align-items: center;
382 justify-content: center;
383 gap: 6px;
384 border: 1px solid var(--shell-hairline);
385 border-radius: 9px;
386 background: var(--shell-field);
387 font-size: 12.5px;
388 font-weight: 500;
389 transition:
390 color 160ms,
391 border-color 160ms,
392 background 160ms;
393}
394.repo-action-button:hover:not(:disabled) {
395 border-color: var(--shell-tile);
396 color: var(--shell-ink);
397}
398.repo-action-button.is-on {
399 border-color: var(--shell-tile);
400 background: var(--shell-active);
401}
402.repo-action-button:disabled {
403 opacity: 60%;
404}
405.repo-commit-bar {
406 min-height: 42px;
407 padding: 7px 12px;
408 border-bottom: 1px solid var(--shell-hairline);
409}
410.repo-commit-bar-main {
411 min-height: 28px;
412 border-radius: 7px;
413 padding: 2px 6px;
414 transition: background 150ms;
415}
416.repo-commit-bar-main:hover {
417 background: var(--shell-field);
418}
419.repo-commit-history {
420 border-radius: 7px;
421 padding: 4px 8px;
422 color: var(--shell-ink-muted);
423 font-size: 12.5px;
424 transition:
425 color 150ms,
426 background 150ms;
427}
428.repo-commit-history:hover {
429 color: var(--shell-ink);
430 background: var(--shell-field);
431}
432.repo-branch-list {
433 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
434}
435.repo-branch-row {
436 transition: background 150ms;
437}
438.repo-branch-row:hover {
439 background: color-mix(in srgb, var(--shell-field) 80%, transparent);
440}
441.repo-commit-list {
442 display: flex;
443 flex-direction: column;
444 gap: 8px;
445}
446.repo-commit-day {
447 color: var(--shell-ink-muted);
448 font-size: 12px;
449 font-weight: 600;
450 letter-spacing: 0.01em;
451}
452.repo-commit-day-rule {
453 height: 1px;
454 min-width: 24px;
455 flex: 1 1 auto;
456 background: var(--shell-hairline);
457}
458.repo-commit-row {
459 border: 1px solid var(--shell-hairline);
460 border-radius: 12px;
461 background: var(--shell-surface);
462 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
463 transition:
464 border-color 150ms,
465 box-shadow 150ms;
466}
467.repo-commit-row:hover {
468 border-color: var(--shell-tile);
469 box-shadow: 0 2px 8px rgb(24 28 31 / 6%);
470}
471.repo-commit-subject {
472 text-underline-offset: 3px;
473}
474.repo-commit-tag {
475 display: inline-flex;
476 align-items: center;
477 gap: 4px;
478 border: 1px solid var(--shell-hairline);
479 border-radius: 999px;
480 background: var(--shell-field);
481 padding: 1px 8px;
482 font-family: var(--font-mono, ui-monospace, monospace);
483 font-size: 11px;
484 line-height: 1.6;
485}
486.repo-branch-search input:focus,
487.repo-settings-input:focus {
488 box-shadow: 0 0 0 3px rgb(35 39 43 / 5%);
489}
490.repo-delete-button:not(:disabled) {
491 color: #a34343;
492}
493.repo-delete-button:not(:disabled):hover {
494 border-color: #c96868;
495 background: #f9eeee;
496 color: #8b3232;
497}
498.dark .repo-delete-button:not(:disabled):hover {
499 background: #3a2727;
500}
501.repo-settings-nav button {
502 min-height: 36px;
503 border-radius: 8px;
504 padding: 6px 10px;
505 color: var(--shell-ink-muted);
506 font-size: 13px;
507 transition:
508 color 160ms,
509 background 160ms;
510}
511.repo-settings-nav button:hover {
512 color: var(--shell-ink);
513 background: var(--shell-field);
514}
515.repo-settings-nav button[aria-current="page"] {
516 color: var(--shell-ink-muted);
517 background: var(--shell-active);
518 font-weight: 600;
519}
520.repo-setting-fact {
521 border-bottom: 1px solid color-mix(in srgb, var(--shell-hairline) 75%, transparent);
522}
523.repo-empty {
524 box-shadow: 0 1px 2px rgb(24 28 31 / 2%);
525}
526
527@media (max-width: 760px) {
528 .repo-view-content {
529 padding-top: 0;
530 }
531 .repo-commit-row,
532 .repo-branch-row {
533 flex-wrap: wrap;
534 row-gap: 8px;
535 }
536 .repo-toolbar-tabs nav > button,
537 .repo-toolbar-tabs nav > span {
538 padding-inline: 10px;
539 }
540 .repo-file-toolbar {
541 padding: 8px 10px;
542 }
543 .repo-directory-header {
544 padding: 8px 10px;
545 }
546 .repo-code-header {
547 padding: 8px;
548 }
549 .repo-file-table th,
550 .repo-file-table td {
551 padding-left: 12px;
552 padding-right: 12px;
553 }
554 .repo-commit-row {
555 padding: 14px;
556 }
557 .repo-commit-actions {
558 width: auto;
559 flex: 1 1 100%;
560 margin-left: 0;
561 justify-content: flex-end;
562 }
563 .repo-branch-row {
564 padding: 14px;
565 }
566 .repo-branch-actions {
567 width: auto;
568 flex: 1 1 100%;
569 margin-left: 0;
570 }
571 .repo-settings-content {
572 padding: 18px;
573 }
574}
Added view/app/src/pages/workspace/ui/repo/repo-view.tsx view/app/src/pages/workspace/ui/repo/repo-view.tsx +386 −0 View view/app/src/pages/workspace/ui/repo/repo-view.tsx
@@ -0,0 +1,386 @@
1import { PullDetail, PullsTab } from '@app/domains/pulls'
2import { BranchesTab } from '@app/domains/repo/branches'
3import { CodeTab } from '@app/domains/repo/browse'
4import { CommitsTab, CommitView } from '@app/domains/repo/history'
5import { cloneTargets, fetchHtmlFragment, fetchRepo, htmlFragmentUrl } from '@app/domains/repo/model'
6import { ReleasesTab } from '@app/domains/repo/releases'
7import { SettingsTab } from '@app/domains/repo/settings'
8import type { MySpace } from '@app/domains/space/management'
9import { useResourceEvents } from '@app/services/realtime/topic'
10import { useT } from '@app/i18n'
11import { repoKey, useAsync } from '@app/lib'
12import { ArchiveRestore } from 'lucide-react'
13import { useEffect, useMemo, useRef, useState } from 'react'
14import { useRepoViewLocation } from '../../model/repo-location'
15import { RepoLayout } from '../layout/repo-layout'
16import { RepoCloneMenu } from './clone-menu'
17import { RefPicker } from './ref-picker'
18import { RepoAside } from './repo-aside'
19import './repo-polish.css'
20
21export interface RepoViewProps {
22 spaceName: string
23 repoName: string
24 /** The viewer's /me/spaces row for this space — carries the space owner
25 * id; repo settings are owner-only server-side, so the tab needs it too. */
26 mySpace?: MySpace
27 /** Signed-in account id, compared against the space owner. */
28 viewerId?: number
29 /** AppShell slots owned by the workspace frame (rail / nav / footer). */
30 shell: {
31 rail?: React.ReactNode
32 sideHeader?: React.ReactNode
33 navPinned?: React.ReactNode
34 nav?: React.ReactNode
35 sideFooter?: React.ReactNode
36 }
37}
38
39type RenderMode = 'html' | 'json'
40
41/**
42 * Route-level repository page.
43 *
44 * Every piece of view state — tab, revision, directory, opened file, open
45 * commit, history path filter, first-parent toggle and settings section — lives
46 * in the route's query string (`model/repo-location`), so any screen is
47 * addressable, survives a refresh, and is walked by the browser's back/forward
48 * buttons. All of it moves through react-router, never through
49 * `window.location`, so the repo page cannot trigger a document reload.
50 *
51 * HTML fragments render tree/blob/readme when the endpoint accepts our auth
52 * (public repos); private repos fall back to the JSON API.
53 */
54const REF_TAGS = [
55 'create-branch',
56 'update-branch',
57 'delete-branch',
58 'rename-branch',
59 'set-default-branch',
60 'create-tag',
61 'delete-tag',
62] as const
63
64export function RepoView({ spaceName, repoName, mySpace, viewerId, shell }: RepoViewProps) {
65 const { t } = useT()
66 const [tick, setTick] = useState(0)
67 const repoState = useAsync(() => fetchRepo(spaceName, repoName), [spaceName, repoName, tick], {
68 cacheKey: repoKey(spaceName, repoName, 'detail'),
69 })
70 const repoId = repoState.data?.id
71 useResourceEvents({
72 topic: 'kenshell-ref',
73 tags: REF_TAGS,
74 filter: repoId ? { repo_id: repoId } : undefined,
75 enabled: Boolean(repoId),
76 onEvent: () => setTick((value) => value + 1),
77 })
78 useResourceEvents({
79 topic: 'kenshell-commit',
80 tags: ['sync'],
81 filter: repoId ? { repo_id: repoId } : undefined,
82 enabled: Boolean(repoId),
83 onEvent: () => setTick((value) => value + 1),
84 })
85 const repo = repoState.data
86 const {
87 view,
88 patch,
89 setTab,
90 setRev,
91 setPath,
92 openFile,
93 closeFile,
94 openCommit,
95 closeCommit,
96 showHistory,
97 setFirstParent,
98 setDiffStyle,
99 setSection,
100 browseRev,
101 normalizeForRepo,
102 } = useRepoViewLocation()
103 const {
104 tab,
105 rev,
106 path,
107 file: filePath,
108 oid,
109 line,
110 commit,
111 firstParent,
112 diff,
113 pull: pullRef,
114 ptab,
115 release,
116 section,
117 } = view
118 const file = filePath ? { path: filePath, oid: oid || undefined } : null
119 const [mode, setMode] = useState<RenderMode>('html')
120 // Clone addresses come from the backend — never guessed from window.location.
121 const clones = useMemo(() => (repo ? cloneTargets(repo) : []), [repo])
122 // 仓库的写操作(分支/标签/发布/流水线/合并请求)与设置都是 owner / 仓库协作者
123 // 专属:服务端一律 owner 或 collab Write·Admin,协作成员身份 Web 端拿不到,
124 // 所以按空间权限上下文收敛到所有者。
125 const ownerUid =
126 mySpace?.space.owner?.user_id ?? (mySpace?.space as unknown as { owner_uid?: string } | undefined)?.owner_uid
127 const canManageRepo = Boolean(ownerUid && viewerId && ownerUid === viewerId)
128 // PR 详情面板的展开/收回动画:打开时向下展开,关闭时向上收拢 300ms 后再卸载,
129 // 因此面板在关闭动画播放期间保持挂载(`detailRef` 在动画结束后才清空)。
130 const detailOpen = tab === 'pull' && Boolean(pullRef)
131 const [detailRef, setDetailRef] = useState<string | null>(null)
132 if (detailOpen && pullRef && detailRef !== pullRef) setDetailRef(pullRef)
133 useEffect(() => {
134 if (detailOpen || detailRef == null) return
135 const timer = setTimeout(() => setDetailRef(null), 320)
136 return () => clearTimeout(timer)
137 }, [detailOpen, detailRef])
138
139 // The repo DTO only contributes its default revision. A fresh mount keeps the
140 // deep-linked view as-is (only filling in ?rev= when it is missing); switching
141 // repository behind the same route component rewrites the params, because
142 // branches, tree paths and commit shas from the previous repo do not exist here.
143 const normalizedKey = useRef<string | null>(null)
144 useEffect(() => {
145 if (!repo) return
146 const key = `${spaceName}/${repoName}#${repo.id}`
147 if (normalizedKey.current === key) return
148 if (normalizedKey.current === null) {
149 normalizedKey.current = key
150 if (!rev && repo.default_branch) patch({ rev: repo.default_branch }, { replace: true })
151 return
152 }
153 normalizedKey.current = key
154 normalizeForRepo(repo.default_branch)
155 // eslint-disable-next-line react-hooks/exhaustive-deps
156 }, [spaceName, repoName, repo?.id, rev])
157
158 // Probe an HTML fragment: 401/403 means the endpoint rejects our session
159 // (private repo needs a Basic access key) → switch the whole page to JSON.
160 useEffect(() => {
161 if (!repo) return
162 let active = true
163 fetchHtmlFragment(htmlFragmentUrl(spaceName, repoName, 'last-commit', { revision: repo.default_branch })).then(
164 (probe) => {
165 if (active) setMode(probe.status === 401 || probe.status === 403 ? 'json' : 'html')
166 },
167 )
168 return () => {
169 active = false
170 }
171 }, [repo, spaceName, repoName])
172
173 // 无管理权限时设置页不渲染:深链或历史记录落到 ?tab=settings 的一律静默
174 // 替换为代码页,URL 里不留一个打不开的入口。
175 useEffect(() => {
176 if (!repo || canManageRepo || tab !== 'settings') return
177 patch({ tab: 'code' }, { replace: true })
178 }, [repo, canManageRepo, tab, patch])
179
180 // 被仓库设置关闭的页签同样不允许深链停留:静默落到代码页,URL 里不留
181 // 一个页签条上不存在的入口。
182 useEffect(() => {
183 if (!repo) return
184 const prHidden = !repo.allow_pull_requests && (tab === 'pulls' || tab === 'pull')
185 if (prHidden) patch({ tab: 'code' }, { replace: true })
186 }, [repo, tab, patch])
187
188 if (repoState.loading && !repo) {
189 return (
190 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`} canAdmin={canManageRepo}>
191 <div className="flex h-full items-center justify-center text-[13px] text-shell-ink-muted">
192 {t('shell.repo.loading')}
193 </div>
194 </RepoLayout>
195 )
196 }
197
198 if (!repo) {
199 const failure = repoState.error as { code?: number; message?: string } | undefined
200 return (
201 <RepoLayout {...shell} title={`${spaceName} / ${repoName}`} canAdmin={canManageRepo}>
202 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
203 <p className="text-[15px] font-semibold">
204 {failure?.code === 404 ? t('shell.repo.notFound') : failure?.message || t('shell.repo.loadFailed')}
205 </p>
206 <button
207 type="button"
208 onClick={() => setTick((t) => t + 1)}
209 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
210 >
211 {t('common.action.retry')}
212 </button>
213 </div>
214 </RepoLayout>
215 )
216 }
217
218 const refsPicker = (
219 <RefPicker
220 space={spaceName}
221 repo={repoName}
222 rev={rev}
223 onRevChange={setRev}
224 branchesCount={repo.branches_count ?? 0}
225 tagsCount={repo.tags_count ?? 0}
226 commitsCount={repo.commits_count ?? 0}
227 onShowCommits={() => setTab('commits')}
228 />
229 )
230 // 克隆菜单跟着 refs 行走:代码页签的 refs 在内容卡片里,克隆放行尾;其它页签留在工具栏。
231 const cloneMenu = (
232 <RepoCloneMenu targets={clones} space={spaceName} repo={repoName} rev={rev || repo.default_branch || ''} />
233 )
234 return (
235 <RepoLayout
236 {...shell}
237 title={`${spaceName} / ${repo.name}`}
238 topic={repo.description ?? undefined}
239 clone={tab === 'code' ? undefined : cloneMenu}
240 activeTab={tab}
241 onTabChange={setTab}
242 canAdmin={canManageRepo}
243 pullsCount={repo.pulls_count}
244 showAside={tab === 'code'}
245 tabOptions={{
246 allowPullRequests: repo.allow_pull_requests,
247 order: repo.tab_order,
248 }}
249 aside={
250 <RepoAside
251 space={spaceName}
252 repoName={repoName}
253 repo={repo}
254 onChanged={() => setTick((t) => t + 1)}
255 onOpenCommits={() => setTab('commits')}
256 onOpenReleases={() => setTab('releases')}
257 mode={mode}
258 rev={rev}
259 />
260 }
261 >
262 <div className="repo-view-content">
263 {repo.is_archived ? (
264 <div className="mb-3 flex items-center gap-2 rounded-md border-2 border-[#f0b232]/50 bg-[#f0b232]/10 px-3 py-2 text-[12.5px] text-[#8a6100]">
265 <ArchiveRestore className="h-4 w-4 shrink-0" aria-hidden />
266 {t('shell.repo.archived')}
267 </div>
268 ) : null}
269 {tab === 'code' && (rev || repo.commits_count === 0) ? (
270 <CodeTab
271 key={rev}
272 space={spaceName}
273 repo={repoName}
274 rev={rev}
275 repoDto={repo}
276 refs={refsPicker}
277 clone={cloneMenu}
278 mode={mode}
279 path={path}
280 file={file}
281 line={line || undefined}
282 onNavigate={setPath}
283 openFile={openFile}
284 closeFile={closeFile}
285 onOpenCommit={(sha) => patch({ tab: 'commits', commit: sha })}
286 onShowHistory={showHistory}
287 />
288 ) : null}
289 {tab === 'commits' && rev ? (
290 commit ? (
291 <CommitView
292 key={commit}
293 space={spaceName}
294 repo={repoName}
295 sha={commit}
296 onBack={closeCommit}
297 onOpenCommit={openCommit}
298 onBrowseRevision={browseRev}
299 diffStyle={diff}
300 onDiffStyleChange={setDiffStyle}
301 />
302 ) : (
303 <CommitsTab
304 key={`commits:${rev}:${path}`}
305 space={spaceName}
306 repo={repoName}
307 rev={rev}
308 path={path || undefined}
309 onOpenCommit={openCommit}
310 onShowHistory={(target) => patch({ commit: '', path: target })}
311 firstParent={firstParent}
312 onFirstParentChange={setFirstParent}
313 />
314 )
315 ) : null}
316 {tab === 'pulls' || tab === 'pull' ? (
317 <>
318 <PullsTab
319 space={spaceName}
320 repo={repoName}
321 repoId={repo.id}
322 canWrite={canManageRepo}
323 defaultBranch={repo.default_branch}
324 activePull={pullRef ?? null}
325 onOpenPull={(ref) => patch({ tab: 'pull', pull: ref, ptab: 'conversation' })}
326 onClosePull={() => patch({ tab: 'pulls', pull: '' })}
327 />
328 <div
329 className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
330 detailOpen ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0'
331 }`}
332 >
333 <div className="min-h-0 overflow-hidden">
334 {detailRef ? (
335 <PullDetail
336 key={detailRef}
337 canWrite={canManageRepo}
338 space={spaceName}
339 repo={repoName}
340 pullRef={detailRef}
341 ptab={ptab}
342 onPtabChange={(next) => patch({ ptab: next })}
343 onBackToList={() => patch({ tab: 'pulls', pull: '' })}
344 onOpenCommit={(sha) => patch({ tab: 'commits', commit: sha })}
345 />
346 ) : null}
347 </div>
348 </div>
349 </>
350 ) : null}
351 {tab === 'branches' ? (
352 <BranchesTab
353 space={spaceName}
354 repo={repoName}
355 rev={rev}
356 defaultBranch={repo.default_branch}
357 canWrite={canManageRepo}
358 onCheckout={setRev}
359 onBrowse={browseRev}
360 onChanged={() => setTick((t) => t + 1)}
361 />
362 ) : null}
363 {tab === 'releases' ? (
364 <ReleasesTab
365 space={spaceName}
366 repo={repoName}
367 selected={release}
368 onSelect={(tag) => patch({ release: tag })}
369 canWrite={canManageRepo}
370 />
371 ) : null}
372 {tab === 'settings' && canManageRepo ? (
373 <SettingsTab
374 space={spaceName}
375 repo={repoName}
376 repoDto={repo}
377 rev={rev}
378 section={section}
379 onSectionChange={setSection}
380 onSaved={() => setTick((t) => t + 1)}
381 />
382 ) : null}
383 </div>
384 </RepoLayout>
385 )
386}
Added view/app/src/pages/workspace/ui/asset-menu.tsx view/app/src/pages/workspace/ui/asset-menu.tsx +315 −0 View view/app/src/pages/workspace/ui/asset-menu.tsx
@@ -0,0 +1,315 @@
1import { type MessageKey, useT } from '@app/i18n'
2import { spaceBoardsKey, spaceReposKey, spaceRoomsKey, useAsync } from '@app/lib'
3import { ChevronDown, FolderGit2, Hash, Search, SquareKanban } from 'lucide-react'
4import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
5import { useNavigate } from 'react-router-dom'
6import { fetchSpaceBoards, fetchSpaceRepos, fetchSpaceRooms } from '../model/shell-api'
7import { useShellRoute } from '../model/workspace-route'
8
9/** 可被名字直达的空间资产种类(与侧边栏顶层行一一对应)。 */
10type AssetKind = 'repo' | 'board' | 'room'
11
12interface AssetOption {
13 key: string
14 kind: AssetKind
15 name: string
16 href: string
17}
18
19/** 默认态分组顺序;搜索时同一批数据参与打分排序。 */
20const KINDS: AssetKind[] = ['repo', 'board', 'room']
21
22const KIND_ICONS: Record<AssetKind, ReactNode> = {
23 repo: <FolderGit2 className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
24 board: <SquareKanban className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
25 room: <Hash className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />,
26}
27
28const KIND_LABEL_KEYS: Record<AssetKind, MessageKey> = {
29 repo: 'shell.nav.repo',
30 board: 'shell.nav.board',
31 room: 'shell.sidebar.rooms',
32}
33
34const MAX_MATCHES = 20
35/** 默认态每一类最多列出的行数,超出的靠输入过滤。 */
36const GROUP_LIMIT = 6
37
38/** 面板的一行:类型标题或一个可跳转的资产。 */
39type Row = { type: 'header'; kind: AssetKind } | { type: 'item'; option: AssetOption; index: number }
40
41/**
42 * 顶栏标题上的空间资产速查:标题默认原样渲染,点击弹出下拉搜索框,
43 * 未输入时按类型分组列出当前空间资产,输入即按名字过滤并跳转。
44 *
45 * 数据与侧边栏共用同一批缓存键,本地优先:`useWorkspaceData` 已加载的列
46 * 表会被同步命中,网络刷新在后台去重进行。
47 */
48export function SpaceAssetMenu({ title }: { title: ReactNode }) {
49 const { t } = useT()
50 const navigate = useNavigate()
51 const { spaceName } = useShellRoute()
52 const scoped = spaceName !== '' && spaceName !== '@me'
53
54 const repos = useAsync(() => (scoped ? fetchSpaceRepos(spaceName) : Promise.resolve([])), [spaceName, scoped], {
55 cacheKey: scoped ? spaceReposKey(spaceName) : null,
56 })
57 const boards = useAsync(() => (scoped ? fetchSpaceBoards(spaceName) : Promise.resolve([])), [spaceName, scoped], {
58 cacheKey: scoped ? spaceBoardsKey(spaceName) : null,
59 })
60 const rooms = useAsync(() => (scoped ? fetchSpaceRooms(spaceName) : Promise.resolve([])), [spaceName, scoped], {
61 cacheKey: scoped ? spaceRoomsKey(spaceName) : null,
62 })
63
64 const [open, setOpen] = useState(false)
65 const [query, setQuery] = useState('')
66 const [active, setActive] = useState(0)
67 const rootRef = useRef<HTMLDivElement>(null)
68 const triggerRef = useRef<HTMLButtonElement>(null)
69 const inputRef = useRef<HTMLInputElement>(null)
70
71 const options = useMemo<AssetOption[]>(() => {
72 if (!scoped) return []
73 const spacePath = encodeURIComponent(spaceName)
74 const rows: AssetOption[] = []
75 for (const repo of repos.data ?? []) {
76 rows.push({
77 key: `repo:${repo.id}`,
78 kind: 'repo',
79 name: repo.name,
80 href: `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`,
81 })
82 }
83 for (const board of boards.data ?? []) {
84 rows.push({
85 key: `board:${board.id}`,
86 kind: 'board',
87 name: board.name,
88 href: `/app/${spacePath}/board/${encodeURIComponent(board.id)}`,
89 })
90 }
91 for (const room of rooms.data ?? []) {
92 if (room.kind === 'Category' || room.kind === 'Thread' || room.hidden) continue
93 rows.push({
94 key: `room:${room.id}`,
95 kind: 'room',
96 name: room.name,
97 href: `/app/${spacePath}/chat/${encodeURIComponent(room.id)}`,
98 })
99 }
100 return rows
101 }, [scoped, spaceName, repos.data, boards.data, rooms.data])
102
103 const needle = query.trim().toLowerCase()
104 const matched = useMemo(() => {
105 if (!needle) return []
106 const scored: Array<{ option: AssetOption; score: number }> = []
107 for (const option of options) {
108 const at = option.name.toLowerCase().indexOf(needle)
109 if (at < 0) continue
110 // 前缀/词首命中排在内文命中之前,其次按命中位置与名字排序。
111 const boundary = at === 0 || /[\s\-_/.]/.test(option.name[at - 1] ?? '')
112 scored.push({ option, score: (boundary ? 0 : 1000) + at })
113 }
114 scored.sort((a, b) => a.score - b.score || a.option.name.localeCompare(b.option.name))
115 return scored.slice(0, MAX_MATCHES).map((entry) => entry.option)
116 }, [needle, options])
117
118 const rows = useMemo<Row[]>(() => {
119 const out: Row[] = []
120 if (needle) {
121 matched.forEach((option, index) => out.push({ type: 'item', option, index }))
122 return out
123 }
124 let index = 0
125 for (const kind of KINDS) {
126 const items = options.filter((option) => option.kind === kind).slice(0, GROUP_LIMIT)
127 if (items.length === 0) continue
128 out.push({ type: 'header', kind })
129 for (const option of items) {
130 out.push({ type: 'item', option, index })
131 index += 1
132 }
133 }
134 return out
135 }, [needle, matched, options])
136
137 const items = useMemo(() => rows.filter((row): row is Extract<Row, { type: 'item' }> => row.type === 'item'), [rows])
138 const loading = repos.loading || boards.loading || rooms.loading
139 const activeIndex = items.length === 0 ? 0 : Math.min(active, items.length - 1)
140
141 useEffect(() => {
142 if (!open) return
143 inputRef.current?.focus()
144 }, [open])
145
146 useEffect(() => {
147 if (!open) return
148 const onPointerDown = (event: PointerEvent) => {
149 if (!rootRef.current?.contains(event.target as Node)) close()
150 }
151 const onKeyDown = (event: globalThis.KeyboardEvent) => {
152 if (event.key !== 'Escape') return
153 close()
154 triggerRef.current?.focus()
155 }
156 document.addEventListener('pointerdown', onPointerDown)
157 document.addEventListener('keydown', onKeyDown)
158 return () => {
159 document.removeEventListener('pointerdown', onPointerDown)
160 document.removeEventListener('keydown', onKeyDown)
161 }
162 // `close` 只做本地状态复位,不随渲染变化。
163 // eslint-disable-next-line react-hooks/exhaustive-deps
164 }, [open])
165
166 useEffect(() => {
167 if (!open || items.length === 0) return
168 const node = rootRef.current?.querySelector<HTMLElement>(`[data-asset-index="${activeIndex}"]`)
169 node?.scrollIntoView({ block: 'nearest' })
170 }, [open, activeIndex, items.length])
171
172 if (!scoped) return <h1 className="truncate text-base font-semibold">{title}</h1>
173
174 function close() {
175 setOpen(false)
176 setQuery('')
177 setActive(0)
178 }
179
180 function pick(option: AssetOption) {
181 navigate(option.href)
182 close()
183 }
184
185 function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
186 if (event.key === 'Escape') {
187 close()
188 triggerRef.current?.focus()
189 return
190 }
191 if (items.length === 0) return
192 if (event.key === 'ArrowDown') {
193 event.preventDefault()
194 setActive((activeIndex + 1) % items.length)
195 } else if (event.key === 'ArrowUp') {
196 event.preventDefault()
197 setActive((activeIndex - 1 + items.length) % items.length)
198 } else if (event.key === 'Enter') {
199 event.preventDefault()
200 pick(items[activeIndex].option)
201 }
202 }
203
204 return (
205 <div ref={rootRef} className="relative min-w-0">
206 <h1 className="min-w-0">
207 <button
208 ref={triggerRef}
209 type="button"
210 aria-haspopup="dialog"
211 aria-expanded={open}
212 aria-controls="workspace-asset-menu"
213 title={t('shell.search.placeholder')}
214 onClick={() => (open ? close() : setOpen(true))}
215 className="-ml-1.5 flex min-w-0 items-center gap-1.5 rounded-lg px-1.5 py-0.5 text-base font-semibold transition-colors hover:bg-shell-field"
216 >
217 {/* 标题必须完整展示:空间不足时换行,不截断。 */}
218 <span className="min-w-0 break-words">{title}</span>
219 <ChevronDown
220 className={`h-3.5 w-3.5 shrink-0 text-shell-ink-muted transition-transform ${open ? 'rotate-180' : ''}`}
221 aria-hidden
222 />
223 </button>
224 </h1>
225 {open ? (
226 <div
227 id="workspace-asset-menu"
228 role="dialog"
229 aria-label={t('shell.search.placeholder')}
230 className="absolute left-0 top-full z-50 mt-2 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface shadow-xl"
231 >
232 <div className="flex items-center gap-2 border-b border-shell-hairline px-3">
233 <Search className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
234 <input
235 ref={inputRef}
236 value={query}
237 onChange={(event) => {
238 setQuery(event.target.value)
239 setActive(0)
240 }}
241 onKeyDown={onKeyDown}
242 role="combobox"
243 aria-expanded={items.length > 0}
244 aria-controls="workspace-asset-menu-list"
245 aria-activedescendant={items.length > 0 ? `workspace-asset-option-${activeIndex}` : undefined}
246 aria-autocomplete="list"
247 placeholder={t('shell.search.placeholder')}
248 className="h-10 min-w-0 flex-1 bg-transparent text-[13.5px] text-shell-ink outline-none placeholder:text-shell-ink-muted"
249 />
250 </div>
251 <div
252 id="workspace-asset-menu-list"
253 role="listbox"
254 aria-label={t('shell.search.placeholder')}
255 className="max-h-[380px] overflow-y-auto p-1.5"
256 >
257 {items.length === 0 ? (
258 <p className="px-3 py-6 text-center text-[12.5px] text-shell-ink-muted">
259 {loading ? t('common.action.loading') : t('shell.search.empty')}
260 </p>
261 ) : (
262 rows.map((row) =>
263 row.type === 'header' ? (
264 <p
265 key={`header:${row.kind}`}
266 className="px-2.5 pb-1 pt-2 text-[11px] font-semibold text-shell-ink-muted"
267 >
268 {t(KIND_LABEL_KEYS[row.kind])}
269 </p>
270 ) : (
271 <button
272 key={row.option.key}
273 id={`workspace-asset-option-${row.index}`}
274 data-asset-index={row.index}
275 type="button"
276 role="option"
277 aria-selected={row.index === activeIndex}
278 onMouseEnter={() => setActive(row.index)}
279 onClick={() => pick(row.option)}
280 className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] ${
281 row.index === activeIndex ? 'bg-shell-field text-shell-ink' : 'text-shell-ink/85'
282 }`}
283 >
284 {KIND_ICONS[row.option.kind]}
285 <span className="min-w-0 flex-1 truncate">
286 <Highlighted name={row.option.name} needle={needle} />
287 </span>
288 {needle ? (
289 <span className="shrink-0 rounded bg-shell-active px-1.5 py-0.5 text-[10px] text-shell-ink-muted">
290 {t(KIND_LABEL_KEYS[row.option.kind])}
291 </span>
292 ) : null}
293 </button>
294 ),
295 )
296 )}
297 </div>
298 </div>
299 ) : null}
300 </div>
301 )
302}
303
304/** 名字中命中的那一段加粗,其余保持原样。 */
305function Highlighted({ name, needle }: { name: string; needle: string }) {
306 const at = name.toLowerCase().indexOf(needle)
307 if (at < 0) return <>{name}</>
308 return (
309 <>
310 {name.slice(0, at)}
311 <mark className="bg-transparent font-semibold text-shell-ink">{name.slice(at, at + needle.length)}</mark>
312 {name.slice(at + needle.length)}
313 </>
314 )
315}
Added view/app/src/pages/workspace/ui/members-view.tsx view/app/src/pages/workspace/ui/members-view.tsx +224 −0 View view/app/src/pages/workspace/ui/members-view.tsx
@@ -0,0 +1,224 @@
1import { AppShell, type AppShellProps, ShellTopBar } from '@app/blocks'
2import { fmtDate, InitialAvatar } from '@app/domains/shared'
3import { fetchSpace } from '@app/domains/space/management/model/api'
4import { listSpaceMembers, type SpaceMember } from '@app/domains/space/management/model/space-admin'
5import { MEMBER_STATUS_LABELS } from '@app/domains/space/management/ui/space-settings/meta'
6import { useT } from '@app/i18n'
7import { spaceAdminMembersKey, useAsync } from '@app/lib'
8import { ArrowDownUp, ChevronLeft, ChevronRight, Crown, RefreshCw, Search, Users } from 'lucide-react'
9import { useState } from 'react'
10
11export interface MembersViewProps {
12 spaceName: string
13 /** AppShell slots owned by the workspace frame. */
14 shell: Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'>
15}
16
17const PAGE_SIZES = [25, 50, 100]
18
19/** Full-page community roster, opened from the member count in the space header. */
20export function MembersView({ spaceName, shell }: MembersViewProps) {
21 const { t } = useT()
22 const [tick, setTick] = useState(0)
23 const [query, setQuery] = useState('')
24 const [sort, setSort] = useState<'joined' | 'name'>('joined')
25 const [pageSize, setPageSize] = useState(PAGE_SIZES[0])
26 const [page, setPage] = useState(0)
27
28 const members = useAsync(() => listSpaceMembers(spaceName), [spaceName, tick], {
29 cacheKey: spaceAdminMembersKey(spaceName),
30 })
31 const space = useAsync(() => fetchSpace(spaceName), [spaceName, tick])
32 const ownerId = space.data?.owner?.user_id ?? (space.data as unknown as { owner_uid?: string } | undefined)?.owner_uid
33
34 const all = members.data ?? []
35 const needle = query.trim().toLowerCase()
36 const filtered = all.filter((member) => {
37 if (!needle) return true
38 const user = member.user
39 return (
40 (user?.username ?? '').toLowerCase().includes(needle) ||
41 (user?.display_name ?? '').toLowerCase().includes(needle) ||
42 (member.alias_name ?? '').toLowerCase().includes(needle)
43 )
44 })
45 const sorted = [...filtered].sort((a, b) =>
46 sort === 'name' ? memberName(a).localeCompare(memberName(b)) : (b.joined_at ?? '').localeCompare(a.joined_at ?? ''),
47 )
48 const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize))
49 const current = Math.min(page, pageCount - 1)
50 const rows = sorted.slice(current * pageSize, current * pageSize + pageSize)
51
52 return (
53 <AppShell {...shell}>
54 <ShellTopBar
55 icon={<Users className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
56 title={t('space.members.title')}
57 topic={t('space.members.sub')}
58 actions={
59 <button
60 type="button"
61 title={t('space.members.refresh')}
62 aria-label={t('space.members.refresh')}
63 onClick={() => setTick((value) => value + 1)}
64 className="workspace-icon-button"
65 >
66 <RefreshCw className="h-4 w-4" aria-hidden />
67 </button>
68 }
69 />
70 <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto bg-shell-field p-6">
71 <div className="flex flex-wrap items-center gap-3">
72 <div className="min-w-0 flex-1">
73 <p className="text-[15px] font-semibold">{t('space.members.recent')}</p>
74 <p className="mt-0.5 text-[12.5px] text-shell-ink-muted">
75 {t('space.members.shown', { shown: rows.length, total: sorted.length })}
76 </p>
77 </div>
78 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5">
79 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
80 <input
81 value={query}
82 onChange={(event) => {
83 setQuery(event.target.value)
84 setPage(0)
85 }}
86 placeholder={t('space.members.searchPlaceholder')}
87 aria-label={t('space.members.searchAria')}
88 className="w-[200px] bg-transparent text-[13px] outline-none"
89 />
90 </label>
91 <label className="flex h-9 items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 text-[13px]">
92 <ArrowDownUp className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
93 <span className="text-shell-ink-muted">{t('space.members.sort')}</span>
94 <select
95 value={sort}
96 onChange={(event) => {
97 setSort(event.target.value as 'joined' | 'name')
98 setPage(0)
99 }}
100 aria-label={t('space.members.sort')}
101 className="bg-transparent outline-none"
102 >
103 <option value="joined">{t('space.members.sortJoined')}</option>
104 <option value="name">{t('space.members.sortName')}</option>
105 </select>
106 </label>
107 </div>
108
109 <div className="overflow-hidden rounded-xl bg-shell-surface">
110 <table className="w-full border-collapse text-[13px]">
111 <thead>
112 <tr className="text-left text-[12px] text-shell-ink-muted">
113 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.name')}</th>
114 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.joined')}</th>
115 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.method')}</th>
116 <th className="px-4 py-2.5 font-semibold">{t('space.members.col.status')}</th>
117 </tr>
118 </thead>
119 <tbody>
120 {rows.map((member) => {
121 const name = memberName(member)
122 const isOwner = Boolean(ownerId && member.user?.user_id === ownerId)
123 return (
124 <tr
125 key={member.user?.user_id ?? member.created_at}
126 className="border-t border-shell-hairline/70 transition-colors hover:bg-shell-field/70"
127 >
128 <td className="px-4 py-2.5">
129 <span className="flex min-w-0 items-center gap-2.5">
130 <InitialAvatar name={name} url={member.alias_avatar_url || member.user?.avatar_url} size={32} />
131 <span className="min-w-0">
132 <span className="flex min-w-0 items-center gap-1.5">
133 <span className="truncate font-medium">{name}</span>
134 {isOwner ? <Crown className="h-3.5 w-3.5 shrink-0 text-[#f0b232]" aria-hidden /> : null}
135 </span>
136 <span className="block truncate text-[12px] text-shell-ink-muted">
137 @{member.user?.username ?? member.user?.user_id}
138 </span>
139 </span>
140 </span>
141 </td>
142 <td className="px-4 py-2.5 text-shell-ink-muted">{fmtDate(member.joined_at)}</td>
143 <td className="px-4 py-2.5 text-shell-ink-muted">
144 {member.invited_by
145 ? t('space.members.invitedBy', {
146 name: member.invited_by.display_name || member.invited_by.username,
147 })
148 : isOwner
149 ? t('space.members.owner')
150 : t('space.members.joinedSelf')}
151 </td>
152 <td className="px-4 py-2.5">
153 <span className="rounded-full bg-shell-field px-2 py-0.5 text-[11.5px] text-shell-ink-muted">
154 {MEMBER_STATUS_LABELS[member.status] ?? member.status}
155 </span>
156 </td>
157 </tr>
158 )
159 })}
160 {rows.length === 0 ? (
161 <tr>
162 <td colSpan={4} className="px-4 py-12 text-center text-[13px] text-shell-ink-muted">
163 {t('space.members.noMatch')}
164 </td>
165 </tr>
166 ) : null}
167 </tbody>
168 </table>
169 </div>
170
171 <div className="flex flex-wrap items-center justify-between gap-3">
172 <label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
173 {t('space.members.rowsPerPage')}
174 <select
175 value={pageSize}
176 onChange={(event) => {
177 setPageSize(Number(event.target.value))
178 setPage(0)
179 }}
180 className="h-8 rounded-lg border border-shell-hairline bg-shell-field px-2 text-[13px] text-shell-ink"
181 >
182 {PAGE_SIZES.map((size) => (
183 <option key={size} value={size}>
184 {size}
185 </option>
186 ))}
187 </select>
188 </label>
189 {pageCount > 1 ? (
190 <div className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
191 <button
192 type="button"
193 aria-label="previous"
194 disabled={current === 0}
195 onClick={() => setPage(Math.max(0, current - 1))}
196 className="workspace-icon-button is-compact"
197 >
198 <ChevronLeft className="h-4 w-4" aria-hidden />
199 </button>
200 <span>
201 {current + 1} / {pageCount}
202 </span>
203 <button
204 type="button"
205 aria-label="next"
206 disabled={current >= pageCount - 1}
207 onClick={() => setPage(Math.min(pageCount - 1, current + 1))}
208 className="workspace-icon-button is-compact"
209 >
210 <ChevronRight className="h-4 w-4" aria-hidden />
211 </button>
212 </div>
213 ) : null}
214 </div>
215 </div>
216 </AppShell>
217 )
218}
219
220function memberName(member: SpaceMember): string {
221 return (
222 member.alias_name || member.user?.display_name || member.user?.username || String(member.user?.user_id ?? '') || '—'
223 )
224}
Added view/app/src/pages/workspace/ui/pending-panel.tsx view/app/src/pages/workspace/ui/pending-panel.tsx +20 −0 View view/app/src/pages/workspace/ui/pending-panel.tsx
@@ -0,0 +1,20 @@
1import { useT } from '@app/i18n'
2import { ArrowLeft, MessagesSquare } from 'lucide-react'
3
4/** Placeholder column shown when no channel / board / repo is selected. */
5export function PendingPanel({ label }: { label: string }) {
6 const { t } = useT()
7 return (
8 <div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center bg-shell-field/50 px-8 py-12 text-center">
9 <div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl border border-shell-hairline bg-shell-surface text-shell-ink-muted shadow-sm">
10 <MessagesSquare size={34} strokeWidth={1.5} />
11 </div>
12 <h1 className="text-2xl font-semibold tracking-tight">{t('shell.pending.title')}</h1>
13 <p className="mt-3 max-w-sm text-sm leading-7 text-shell-ink-muted">{t('shell.pending.body', { label })}</p>
14 <span className="mt-8 inline-flex items-center gap-2 rounded-full bg-shell-field px-4 py-2 text-xs text-shell-ink-muted">
15 <ArrowLeft size={14} />
16 {t('shell.pending.hint')}
17 </span>
18 </div>
19 )
20}
Added view/app/src/pages/workspace/ui/sidebar-nav.tsx view/app/src/pages/workspace/ui/sidebar-nav.tsx +691 −0 View view/app/src/pages/workspace/ui/sidebar-nav.tsx
@@ -0,0 +1,691 @@
1import { getVoiceSpaceStates, isVoiceRoom } from '@app/domains/chat/model'
2import { moveRoom, moveSpaceDir } from '@app/domains/space/management'
3import { useT } from '@app/i18n'
4import { ApiFailure } from '@app/lib'
5import { AtSign, ChevronRight, File, Folder, FolderGit2, Hash, SquareKanban, Star, Volume2 } from 'lucide-react'
6import { type DragEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
7import { Link } from 'react-router-dom'
8import { betweenPositions, STRIDE } from '../model/nav-order'
9import { prefetchBoardView, prefetchRepoView, prefetchRoomView } from '../model/prefetch'
10import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
11
12export interface SidebarNavProps {
13 spaceName: string
14 /** Space directory tree (folders + repo/board placeholders). */
15 dirs: DirNode[]
16 rooms: RoomBrief[]
17 dms: DmItem[]
18 repos: RepoItem[]
19 boards: BoardItem[]
20 activeRoomId?: string
21 activeBoardId?: string
22 activeRepoName?: string
23 /** Both move endpoints require the space's `manage_channels` permission. */
24 canOrganize?: boolean
25 /** A move was persisted: the shell refetches the directory tree and rooms. */
26 onChanged?: () => void
27}
28
29/** What a row is, after mapping the directory tree onto the space's assets. */
30type RowKind = 'folder' | 'repo' | 'board' | 'file' | 'room' | 'dm'
31
32/** One row of the view model, positioned inside its (effective) parent level. */
33interface View {
34 key: string
35 /** Which write endpoint moves it: tree node vs chat channel. */
36 type: 'dir' | 'room'
37 id: string
38 kind: RowKind
39 label: string
40 /** Target route, or null for a plain file node (nothing to open). */
41 href: string | null
42 parentId: string | null
43 position: number
44 unread: number
45 favorite: boolean
46 voice: boolean
47 /** Board id behind a row, so the active one can be matched exactly. */
48 assetId?: string
49 /** Peer of a private-message row: its avatar replaces the channel icon. */
50 peer?: { avatar_url?: string; display_name?: string; username?: string } | null
51}
52
53type DropMode = 'into' | 'before' | 'after'
54type DropTarget = { key: string; mode: DropMode } | null
55type Override = { parentId: string | null; position: number }
56
57/**
58 * AppShell `nav` slot: one single list ordered purely by each item's `position`
59 * — folders, channels, repos and boards sort together exactly as the server
60 * returns them — and **every row can be dragged** into a folder, out of it, or to
61 * another slot beside any row. A drop writes the new parent + position through
62 * `PATCH /spaces/{space}/dir/{dir_id}/position` (tree nodes, which is how repos
63 * and boards are placed) or `PATCH /chat/rooms/{room_id}` (channels). The row
64 * moves instantly and snaps back with the server's reason if the write failed.
65 *
66 * Not draggable: assets with no directory node and DM conversations — the API
67 * gives them no position, so they stay appended at the end.
68 */
69export function SidebarNav({
70 spaceName,
71 dirs,
72 rooms,
73 dms,
74 repos,
75 boards,
76 activeRoomId,
77 activeBoardId,
78 activeRepoName,
79 canOrganize = false,
80 onChanged,
81}: SidebarNavProps) {
82 const { t } = useT()
83 const spacePath = encodeURIComponent(spaceName)
84 const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set())
85 const [dragKey, setDragKey] = useState<string | null>(null)
86 const [target, setTarget] = useState<DropTarget>(null)
87 const [notice, setNotice] = useState<string | null>(null)
88 const [busy, setBusy] = useState(false)
89 // Optimistic edits expire as soon as the fetched tree describes something else:
90 // an accepted move is taken over by the refetch, a rejected one disappears.
91 const [pending, setPending] = useState<{ stamp: string; edits: Record<string, Override> }>({
92 stamp: '',
93 edits: {},
94 })
95
96 useEffect(() => {
97 if (!notice) return
98 const timer = setTimeout(() => setNotice(null), 6000)
99 return () => clearTimeout(timer)
100 }, [notice])
101
102 // Live voice occupancy for the channel rows. The webhook writes it into
103 // Redis on the server, so a 10s poll is enough for a sidebar badge and much
104 // cheaper than a socket subscription per row.
105 const [voiceCounts, setVoiceCounts] = useState<Record<string, number>>({})
106 useEffect(() => {
107 // The personal scope has no space-scoped voice endpoint.
108 if (spaceName === '@me') return
109 let cancelled = false
110 const load = async () => {
111 try {
112 const states = await getVoiceSpaceStates(spaceName)
113 if (cancelled) return
114 const next: Record<string, number> = {}
115 for (const room of states.rooms ?? []) next[room.room_id] = room.count
116 setVoiceCounts(next)
117 } catch {
118 // A missing voice surface must never break navigation.
119 }
120 }
121 void load()
122 const timer = setInterval(() => void load(), 10_000)
123 return () => {
124 cancelled = true
125 clearInterval(timer)
126 }
127 }, [spaceName])
128
129 /* ------------------------------------------------------ view model ----- */
130
131 const flat: { node: DirNode; parent: string | null }[] = []
132 const walk = (nodes: DirNode[], parent: string | null) => {
133 for (const node of nodes) {
134 flat.push({ node, parent })
135 walk(node.children ?? [], node.id)
136 }
137 }
138 walk(dirs, null)
139
140 const stamp = JSON.stringify([
141 flat.map(({ node, parent }) => [node.id, parent, node.position]),
142 rooms.map((room) => [room.id, room.parent_id ?? null, room.position]),
143 ])
144 const edits = pending.stamp === stamp ? pending.edits : {}
145
146 const dirIds = new Set(flat.map(({ node }) => node.id))
147 const repoByName = new Map(repos.map((repo) => [repo.name.toLowerCase(), repo]))
148 const boardByDir = new Map(boards.map((board) => [board.dir_id, board]))
149 const visibleRooms = rooms.filter((room) => room.kind !== 'Category' && room.kind !== 'Thread' && !room.hidden)
150
151 const levels = new Map<string | null, View[]>()
152 const push = (parentId: string | null, view: View) => {
153 const bucket = levels.get(parentId)
154 if (bucket) bucket.push(view)
155 else levels.set(parentId, [view])
156 }
157
158 const coveredRepoIds = new Set<string>()
159 const coveredBoardIds = new Set<string>()
160 for (const { node, parent } of flat) {
161 const key = `d:${node.id}`
162 const edit = edits[key]
163 const parentId = (edit ? edit.parentId : (node.parent_dir_id ?? parent)) ?? null
164 const position = edit ? edit.position : node.position
165 let kind: RowKind = 'file'
166 let href: string | null = null
167 let unread = 0
168 const board = boardByDir.get(node.id)
169 const repo = repoByName.get(node.name.toLowerCase())
170 if (node.kind === 'Dir') kind = 'folder'
171 else if (board) {
172 kind = 'board'
173 href = `/app/${spacePath}/board/${encodeURIComponent(board.id)}`
174 unread = board.open_issues_count
175 coveredBoardIds.add(board.id)
176 } else if (repo) {
177 kind = 'repo'
178 href = `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`
179 coveredRepoIds.add(repo.id)
180 }
181 push(parentId, {
182 key,
183 type: 'dir',
184 id: node.id,
185 kind,
186 label: node.name,
187 href,
188 parentId,
189 position,
190 unread,
191 favorite: false,
192 voice: false,
193 assetId: board?.id,
194 })
195 }
196 for (const room of visibleRooms) {
197 const key = `r:${room.id}`
198 const edit = edits[key]
199 const serverParent = room.parent_id && dirIds.has(room.parent_id) ? room.parent_id : null
200 const parentId = (edit ? edit.parentId : serverParent) ?? null
201 push(parentId, {
202 key,
203 type: 'room',
204 id: room.id,
205 kind: 'room',
206 label: room.name,
207 href: `/app/${spacePath}/chat/${room.id}`,
208 parentId,
209 position: edit ? edit.position : room.position,
210 unread: room.unread_count,
211 favorite: room.favorite,
212 voice: isVoiceRoom(room.kind),
213 })
214 }
215 // Tail of the root level, in API order and deliberately *not* draggable:
216 // these rows have no position on the server, so a drop could not be persisted.
217 const tailStart = Number.MAX_SAFE_INTEGER - 2048
218 let tailAt = tailStart
219 // Every tail row is pushed to the root level past the last real position, so
220 // the visual order is: positioned rows first, then the unpositionable extras.
221 const appendTail = (rows: View[]) => {
222 for (const row of rows) {
223 push(null, { ...row, parentId: null, position: tailAt })
224 tailAt += 1
225 }
226 }
227 // Assets the tree does not cover: the directory owns a space's channels,
228 // repos and boards, so these lists stay the fallback for what it misses.
229 appendTail(
230 repos
231 .filter((repo) => !coveredRepoIds.has(repo.id))
232 .map((repo) => ({
233 key: `x:repo-${repo.id}`,
234 type: 'dir' as const,
235 id: '',
236 kind: 'repo' as RowKind,
237 label: repo.name,
238 href: `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`,
239 parentId: null,
240 position: 0,
241 unread: 0,
242 favorite: false,
243 voice: false,
244 assetId: repo.id,
245 })),
246 )
247 appendTail(
248 boards
249 .filter((board) => !coveredBoardIds.has(board.id))
250 .map((board) => ({
251 key: `x:board-${board.id}`,
252 type: 'dir' as const,
253 id: '',
254 kind: 'board' as RowKind,
255 label: board.name,
256 href: `/app/${spacePath}/board/${encodeURIComponent(board.id)}`,
257 parentId: null,
258 position: 0,
259 unread: board.open_issues_count,
260 favorite: false,
261 voice: false,
262 assetId: board.id,
263 })),
264 )
265 if (spaceName === '@me') {
266 appendTail(
267 dms.map((dm) => ({
268 key: `m:${dm.id}`,
269 // No move endpoint exists for a DM, and `id` stays empty so `movable`
270 // refuses a drag instead of failing one.
271 type: 'room' as const,
272 id: '',
273 kind: 'dm' as RowKind,
274 label: dm.peer?.display_name || dm.peer?.username || dm.name,
275 href: `/app/@me/chat/${dm.id}`,
276 parentId: null,
277 position: 0,
278 unread: dm.unread_count,
279 favorite: false,
280 voice: false,
281 peer: dm.peer ?? null,
282 // 选中的仍然是用户 id:`id` 留给移动端点,这里只用于高亮。
283 assetId: dm.id,
284 })),
285 )
286 }
287
288 const byPosition = (a: View, b: View) => a.position - b.position || (a.key < b.key ? -1 : 1)
289 for (const bucket of levels.values()) bucket.sort(byPosition)
290
291 const views = [...levels.values()].flat()
292 const viewByKey = new Map(views.map((view) => [view.key, view]))
293 const movable = (view: View) => canOrganize && view.id !== ''
294
295 /** True when `candidate` is `folderId` itself or lives inside it. */
296 function isInside(candidate: string, folderId: string): boolean {
297 let cursor: string | null = candidate
298 const guard = new Set<string>()
299 while (cursor) {
300 if (cursor === folderId) return true
301 if (guard.has(cursor)) return false
302 guard.add(cursor)
303 cursor = viewByKey.get(`d:${cursor}`)?.parentId ?? null
304 }
305 return false
306 }
307
308 /* --------------------------------------------------------- mutations --- */
309
310 async function write(view: View, parentId: string | null, position: number) {
311 if (view.type === 'dir') {
312 await moveSpaceDir(spaceName, view.id, { parentDirId: parentId, position })
313 return
314 }
315 const row = await moveRoom(view.id, { position, parentId })
316 // An apihost built without room reparenting accepts the PATCH and ignores
317 // the key, so compare the returned row instead of trusting the 200.
318 if ((row.parent_id ?? null) !== parentId) {
319 throw new ApiFailure(t('nav.unsupportedFolderMove'), 501)
320 }
321 }
322
323 async function persist(writes: { view: View; parentId: string | null; position: number }[]) {
324 if (writes.length === 0) return
325 setBusy(true)
326 setNotice(null)
327 setPending((current) => ({
328 stamp,
329 edits: {
330 ...(current.stamp === stamp ? current.edits : {}),
331 ...Object.fromEntries(
332 writes.map((item) => [item.view.key, { parentId: item.parentId, position: item.position }]),
333 ),
334 },
335 }))
336 try {
337 for (const item of writes) await write(item.view, item.parentId, item.position)
338 onChanged?.()
339 } catch (err) {
340 setPending({ stamp, edits: {} })
341 setNotice(err instanceof ApiFailure ? err.message : t('nav.moveFailed'))
342 } finally {
343 setBusy(false)
344 setDragKey(null)
345 setTarget(null)
346 }
347 }
348
349 function dropOn(view: View, mode: DropMode) {
350 const dragged = dragKey ? viewByKey.get(dragKey) : null
351 if (!dragged || !movable(dragged) || busy) return
352 if (dragged.key === view.key) return
353 if (mode === 'into' && view.kind !== 'folder') return
354 if (dragged.kind === 'folder' && view.kind === 'folder' && isInside(view.id, dragged.id)) {
355 setNotice(t('nav.moveIntoSelf'))
356 return
357 }
358
359 const intoFolder = mode === 'into'
360 const parentId = intoFolder ? view.id : view.parentId
361 const siblings = (levels.get(parentId) ?? []).filter((item) => item.key !== dragged.key)
362 const at = intoFolder
363 ? siblings.length
364 : siblings.findIndex((item) => item.key === view.key) + (mode === 'after' ? 1 : 0)
365 const order = [...siblings]
366 order.splice(Math.max(0, Math.min(at, order.length)), 0, dragged)
367
368 const position = betweenPositions(order[at - 1]?.position ?? null, order[at + 1]?.position ?? null)
369 if (position === null) {
370 // Neighbours are adjacent integers: renumber the whole level with a fresh
371 // stride, which also lands the dragged row at its new index.
372 void persist(order.map((item, index) => ({ view: item, parentId, position: (index + 1) * STRIDE })))
373 } else {
374 void persist([{ view: dragged, parentId, position }])
375 }
376 if (intoFolder) setCollapsed((current) => without(current, view.id))
377 }
378
379 function moveToRoot() {
380 const dragged = dragKey ? viewByKey.get(dragKey) : null
381 if (!dragged || !movable(dragged) || busy) return
382 const siblings = (levels.get(null) ?? []).filter((item) => item.key !== dragged.key)
383 const last = siblings[siblings.length - 1]?.position ?? null
384 void persist([{ view: dragged, parentId: null, position: (last ?? 0) + STRIDE }])
385 setTarget(null)
386 }
387
388 function handlersFor(view: View) {
389 if (!movable(view)) return {}
390 return {
391 draggable: true,
392 onDragStart: (event: DragEvent) => {
393 event.dataTransfer.setData('text/plain', view.key)
394 event.dataTransfer.effectAllowed = 'move'
395 setDragKey(view.key)
396 },
397 onDragEnd: () => {
398 setDragKey(null)
399 setTarget(null)
400 },
401 onDragOver: (event: DragEvent) => {
402 const mode = readMode(event, view)
403 if (!mode || !dragKey || dragKey === view.key) return
404 event.preventDefault()
405 event.dataTransfer.dropEffect = 'move'
406 setTarget((current) => (current?.key === view.key && current.mode === mode ? current : { key: view.key, mode }))
407 },
408 onDrop: (event: DragEvent) => {
409 const mode = readMode(event, view)
410 if (!mode) return
411 event.preventDefault()
412 dropOn(view, mode)
413 },
414 }
415 }
416
417 /**
418 * Hover prefetch: warm the column a row is about to open. Voice channels
419 * carry no transcript, and a DM's room id only lives in its href (the row's
420 * `id` is empty because DMs are not draggable).
421 */
422 const hoverPrefetch = useCallback(
423 (view: View) => {
424 if (view.kind === 'room') {
425 if (!view.voice && view.id) prefetchRoomView(view.id)
426 } else if (view.kind === 'dm') {
427 const roomId = view.href?.split('/').pop()
428 if (roomId) prefetchRoomView(decodeURIComponent(roomId))
429 } else if (view.kind === 'repo') {
430 void prefetchRepoView(spaceName, view.label)
431 } else if (view.kind === 'board' && view.assetId) {
432 prefetchBoardView(spaceName, view.assetId)
433 }
434 },
435 [spaceName],
436 )
437
438 /** Middle band of a folder = "into it"; elsewhere the half decides before/after. */
439 function readMode(event: DragEvent, view: View): DropMode | null {
440 if (!dragKey || dragKey === view.key) return null
441 const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
442 const offset = event.clientY - box.top
443 const band = Math.min(10, box.height * 0.3)
444 if (view.kind === 'folder' && offset > band && offset < box.height - band) return 'into'
445 return offset < box.height / 2 ? 'before' : 'after'
446 }
447
448 /* ---------------------------------------------------------- render ----- */
449
450 function renderLevel(parentId: string | null, depth: number, seen: Set<string>): ReactNode[] {
451 const rows: ReactNode[] = []
452 for (const view of levels.get(parentId) ?? []) {
453 if (view.kind === 'folder' && seen.has(view.id)) continue
454 const isFolder = view.kind === 'folder'
455 const isCollapsed = isFolder && collapsed.has(view.id)
456 rows.push(
457 <Row
458 key={view.key}
459 view={view}
460 depth={depth}
461 collapsed={isCollapsed}
462 active={
463 (view.kind === 'room' && view.id === activeRoomId) ||
464 (view.kind === 'dm' && Boolean(activeRoomId) && view.assetId === activeRoomId) ||
465 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
466 (view.kind === 'repo' && view.label === activeRepoName)
467 }
468 dragging={dragKey === view.key}
469 drop={target?.key === view.key ? target.mode : null}
470 onToggle={() =>
471 setCollapsed((current) =>
472 current.has(view.id) ? without(current, view.id) : new Set([...current, view.id]),
473 )
474 }
475 handlers={handlersFor(view)}
476 onHover={hoverPrefetch}
477 voiceCount={spaceName === '@me' ? undefined : voiceCounts[view.id]}
478 />,
479 )
480 if (isFolder && !isCollapsed) rows.push(...renderLevel(view.id, depth + 1, new Set([...seen, view.id])))
481 }
482 return rows
483 }
484
485 const rootProps = {
486 onDragOver: (event: DragEvent) => {
487 if (!dragKey || event.target !== event.currentTarget) return
488 event.preventDefault()
489 setTarget({ key: '__root__', mode: 'into' })
490 },
491 onDrop: (event: DragEvent) => {
492 if (!dragKey || event.target !== event.currentTarget) return
493 event.preventDefault()
494 moveToRoot()
495 },
496 }
497
498 return (
499 <>
500 {notice ? (
501 <p
502 role="alert"
503 className="mx-2 my-1 rounded-md bg-[#e5484d]/10 px-2 py-1.5 text-[11.5px] leading-4 text-[#b42318]"
504 >
505 {notice}
506 </p>
507 ) : null}
508 <div {...rootProps}>{renderLevel(null, 0, new Set())}</div>
509 {!levels.size ? (
510 <p className="px-2 py-5 text-sm leading-6 text-shell-ink-muted">
511 {t(spaceName === '@me' ? 'nav.emptyDms' : 'nav.empty')}
512 </p>
513 ) : null}
514 {dragKey ? (
515 <div
516 onDragOver={(event) => {
517 event.preventDefault()
518 event.dataTransfer.dropEffect = 'move'
519 setTarget({ key: '__root__', mode: 'into' })
520 }}
521 onDrop={(event) => {
522 event.preventDefault()
523 moveToRoot()
524 }}
525 className="mx-2 my-1 flex h-9 items-center justify-center rounded-md border-2 border-dashed border-shell-hairline text-[12px] text-shell-ink-muted"
526 >
527 {t('nav.dropHere')}
528 </div>
529 ) : null}
530 {busy ? (
531 <p role="status" className="px-2 py-1 text-[11px] text-shell-ink-muted">
532 {t('nav.savingPosition')}
533 </p>
534 ) : null}
535 </>
536 )
537}
538
539function without(set: Set<string>, id: string): Set<string> {
540 if (!set.has(id)) return set
541 const next = new Set(set)
542 next.delete(id)
543 return next
544}
545
546const icons: Record<RowKind, ReactNode> = {
547 folder: <Folder className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
548 room: <Hash className="h-4 w-4" />,
549 dm: <AtSign className="h-4 w-4" />,
550 repo: <FolderGit2 className="h-4 w-4" />,
551 board: <SquareKanban className="h-4 w-4" />,
552 file: <File className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
553}
554
555function Row({
556 view,
557 depth,
558 collapsed,
559 active,
560 dragging,
561 drop,
562 onToggle,
563 handlers,
564 onHover,
565 voiceCount,
566}: {
567 view: View
568 depth: number
569 collapsed: boolean
570 active: boolean
571 dragging: boolean
572 drop: DropMode | null
573 onToggle: () => void
574 handlers: Record<string, unknown>
575 /** Hover prefetch, delayed so a cursor sweeping the list fires nothing. */
576 onHover: (view: View) => void
577 /** Live participants in this voice channel, when > 0. */
578 voiceCount?: number
579}) {
580 const { t } = useT()
581 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
582 const cancelHover = () => clearTimeout(hoverTimer.current)
583 const startHover = () => {
584 cancelHover()
585 hoverTimer.current = setTimeout(() => onHover(view), 120)
586 }
587 useEffect(() => cancelHover, [])
588 const hover = view.href
589 ? { onMouseEnter: startHover, onFocus: startHover, onMouseLeave: cancelHover, onBlur: cancelHover }
590 : {}
591 const highlight =
592 drop === 'into'
593 ? 'bg-shell-surface ring-1 ring-shell-tile'
594 : drop === 'before'
595 ? 'border-t-2 border-shell-ink-muted'
596 : drop === 'after'
597 ? 'border-b-2 border-shell-ink-muted'
598 : ''
599 const shared = `workspace-nav-row flex h-8 items-center gap-1.5 rounded-md pr-2 text-[14px] font-medium ${
600 dragging ? 'opacity-40 ' : ''
601 }${highlight}`
602 const indent = { paddingLeft: 8 + depth * 14 }
603
604 if (view.kind === 'folder') {
605 return (
606 <div
607 {...handlers}
608 data-kind="folder"
609 title={view.label}
610 className={`${shared} ${active ? 'bg-shell-active text-shell-ink' : 'text-shell-ink/80 hover:bg-shell-active/60'}`}
611 style={indent}
612 >
613 <button
614 type="button"
615 aria-expanded={!collapsed}
616 onClick={onToggle}
617 className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-[13.5px] font-semibold"
618 >
619 {icons.folder}
620 <span className="truncate">{view.label}</span>
621 <ChevronRight
622 className={`ml-auto h-3.5 w-3.5 shrink-0 text-shell-ink-muted transition-transform ${
623 collapsed ? '' : 'rotate-90'
624 }`}
625 aria-hidden
626 />
627 </button>
628 </div>
629 )
630 }
631
632 const body = (
633 <>
634 <span className="shrink-0 text-shell-ink-muted">
635 {view.kind === 'room' && view.voice ? (
636 <Volume2 className="h-4 w-4" />
637 ) : view.kind === 'dm' && view.peer?.avatar_url ? (
638 <img src={view.peer.avatar_url} alt="" className="h-4 w-4 rounded-full object-cover" loading="lazy" />
639 ) : (
640 icons[view.kind]
641 )}
642 </span>
643 <span className="truncate">{view.label}</span>
644 {view.favorite ? (
645 <Star className="ml-auto h-3 w-3 shrink-0 fill-[#f0b232] text-[#f0b232]" aria-label={t('nav.favorite')} />
646 ) : null}
647 {view.voice && (voiceCount ?? 0) > 0 ? (
648 <span
649 className={`shrink-0 rounded-full bg-[#2ecc71]/18 px-1.5 text-[11px] font-semibold leading-[20px] text-[#1a7f37] ${
650 view.favorite ? '' : 'ml-auto'
651 }`}
652 title={t('nav.inCall', { count: voiceCount ?? 0 })}
653 >
654 {voiceCount}
655 </span>
656 ) : null}
657 {view.unread > 0 ? (
658 <span
659 className={`shrink-0 rounded-full bg-shell-tile px-1.5 text-[11px] font-semibold leading-[20px] text-shell-ink ${
660 view.favorite ? '' : 'ml-auto'
661 }`}
662 >
663 {view.unread > 99 ? '99+' : view.unread}
664 </span>
665 ) : null}
666 </>
667 )
668
669 if (!view.href) {
670 return (
671 <div {...handlers} className={`${shared} text-shell-ink/60`} style={indent}>
672 {body}
673 </div>
674 )
675 }
676 return (
677 <Link
678 {...handlers}
679 {...hover}
680 to={view.href}
681 title={view.label}
682 aria-current={active ? 'page' : undefined}
683 className={`${shared} ${
684 active ? 'bg-shell-active text-shell-ink' : 'text-shell-ink/60 hover:bg-shell-active/60 hover:text-shell-ink'
685 }`}
686 style={indent}
687 >
688 {body}
689 </Link>
690 )
691}
Added view/app/src/pages/workspace/ui/space-rail.tsx view/app/src/pages/workspace/ui/space-rail.tsx +168 −0 View view/app/src/pages/workspace/ui/space-rail.tsx
@@ -0,0 +1,168 @@
1import { useT } from '@app/i18n'
2import { Layers3, Plus } from 'lucide-react'
3import { type ReactNode, useEffect, useRef } from 'react'
4import { Link } from 'react-router-dom'
5import { initial } from '../model/initial'
6import { prefetchSpaceLists } from '../model/prefetch'
7import type { MySpace } from '../model/workspace'
8
9/** Space list for the AppShell `rail` slot, home / add / discover included. */
10export function SpaceRail({
11 spaces,
12 activeName,
13 discovering = false,
14 onAddSpace,
15}: {
16 spaces: MySpace[]
17 activeName?: string
18 /** `/app/explore` is open: the compass tile takes the active treatment. */
19 discovering?: boolean
20 /** Opens the create-space modal; falls back to a link when absent. */
21 onAddSpace?: () => void
22}) {
23 const { t } = useT()
24 return (
25 <>
26 <RailLink to="/app/@me" active={!activeName && !discovering} label={t('shell.rail.home')}>
27 <Layers3 className="h-[26px] w-[26px]" />
28 </RailLink>
29 <div className="h-0.5 w-8 rounded-full bg-shell-tile" />
30 {spaces.map(({ space }) => (
31 <RailLink
32 key={space.id}
33 to={`/app/${encodeURIComponent(space.name)}`}
34 active={space.name === activeName}
35 label={space.name}
36 onHover={space.name === activeName ? undefined : () => prefetchSpaceLists(space.name)}
37 >
38 {space.avatar_url ? (
39 <img src={space.avatar_url} alt="" className="h-full w-full rounded-[inherit] object-cover" />
40 ) : (
41 <span className="text-[15px] font-semibold">{initial(space.name)}</span>
42 )}
43 </RailLink>
44 ))}
45 {onAddSpace ? (
46 <RailButton onClick={onAddSpace} label={t('shell.rail.addSpace')}>
47 <Plus className="h-[22px] w-[22px]" />
48 </RailButton>
49 ) : (
50 <RailLink to="/app/explore" active={false} label={t('shell.rail.addSpace')} dashed>
51 <Plus className="h-[22px] w-[22px]" />
52 </RailLink>
53 )}
54 <RailLink to="/app/explore" active={discovering} label={t('shell.rail.discover')} dashed>
55 <CompassFilled className="h-[18px] w-[18px]" />
56 </RailLink>
57 </>
58 )
59}
60
61function RailButton({ onClick, label, children }: { onClick: () => void; label: string; children: ReactNode }) {
62 return (
63 <div className="relative flex w-12 justify-center">
64 <button
65 type="button"
66 title={label}
67 aria-label={label}
68 onClick={onClick}
69 className="group relative flex h-12 w-12 items-center justify-center overflow-hidden bg-transparent text-shell-ink transition-colors"
70 >
71 <RailDashedRing />
72 <span className="relative flex items-center justify-center">{children}</span>
73 </button>
74 </div>
75 )
76}
77
78function RailLink({
79 to,
80 active,
81 label,
82 dashed = false,
83 onHover,
84 children,
85}: {
86 to: string
87 active: boolean
88 label: string
89 /** Set the create / discover tiles apart with a dashed ring until they are used. */
90 dashed?: boolean
91 /** Hover prefetch, delayed so a cursor sweeping the rail fires nothing. */
92 onHover?: () => void
93 children: ReactNode
94}) {
95 const hoverTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
96 useEffect(() => () => clearTimeout(hoverTimer.current), [])
97 const hover = onHover
98 ? {
99 onMouseEnter: () => {
100 clearTimeout(hoverTimer.current)
101 hoverTimer.current = setTimeout(onHover, 120)
102 },
103 onFocus: () => {
104 clearTimeout(hoverTimer.current)
105 hoverTimer.current = setTimeout(onHover, 120)
106 },
107 onMouseLeave: () => clearTimeout(hoverTimer.current),
108 onBlur: () => clearTimeout(hoverTimer.current),
109 }
110 : {}
111 return (
112 <div className="relative flex w-12 justify-center">
113 {active ? (
114 <span className="absolute -left-[10px] top-1/2 h-10 w-1 -translate-y-1/2 rounded-r-full bg-shell-ink-muted" />
115 ) : null}
116 <Link
117 to={to}
118 title={label}
119 aria-label={label}
120 aria-current={active ? 'page' : undefined}
121 {...hover}
122 className={`group relative flex h-12 w-12 items-center justify-center overflow-hidden transition-colors ${
123 active
124 ? 'bg-shell-tile font-semibold text-shell-ink'
125 : dashed
126 ? 'bg-transparent text-shell-ink'
127 : 'bg-shell-tile text-shell-ink-muted hover:text-shell-ink'
128 }`}
129 >
130 {dashed ? <RailDashedRing /> : null}
131 <span className="relative flex items-center justify-center">{children}</span>
132 </Link>
133 </div>
134 )
135}
136
137/** Dashed rail ring: long, sparse segments that square off with their tile. */
138function RailDashedRing() {
139 return (
140 <svg aria-hidden viewBox="0 0 48 48" className="pointer-events-none absolute inset-0 h-full w-full text-shell-tile">
141 <rect
142 className="rail-dashed-ring-shape"
143 x="2"
144 y="2"
145 width="44"
146 height="44"
147 fill="none"
148 stroke="currentColor"
149 strokeWidth="2"
150 strokeDasharray="6 8"
151 />
152 </svg>
153 )
154}
155
156/** Filled compass: solid disc with the needle knocked out, matching the rail reference. */
157function CompassFilled({ className }: { className?: string }) {
158 return (
159 <svg viewBox="0 0 24 24" className={className} aria-hidden>
160 <path
161 fillRule="evenodd"
162 clipRule="evenodd"
163 className="fill-current"
164 d="M12 1.5a10.5 10.5 0 1 0 0 21 10.5 10.5 0 0 0 0-21Zm4.8 5.7-4.2 6.6-6.6 4.2 4.2-6.6 6.6-4.2Z"
165 />
166 </svg>
167 )
168}
Added view/app/src/pages/workspace/ui/user-panel.tsx view/app/src/pages/workspace/ui/user-panel.tsx +229 −0 View view/app/src/pages/workspace/ui/user-panel.tsx
@@ -0,0 +1,229 @@
1import { getUserPresence, type PresenceState } from '@app/domains/chat/model'
2import { useT } from '@app/i18n'
3import { useWsUserEvents } from '@app/services/realtime'
4import { useVoiceSession, voiceSession } from '@app/services/voice'
5import { Check, Copy, HeadphoneOff, Headphones, Mic, MicOff, Pencil, Settings } from 'lucide-react'
6import { useEffect, useRef, useState } from 'react'
7import { initial } from '../model/initial'
8import type { Me } from '../model/workspace'
9
10/** AppShell `sideFooter` slot: signed-in identity plus the global voice toggles. */
11export function UserPanel({ me, onSettings }: { me: Me; onSettings?: () => void }) {
12 const { t } = useT()
13 const session = useVoiceSession()
14 const [cardOpen, setCardOpen] = useState(false)
15 const [copied, setCopied] = useState(false)
16 // 自己的在线状态:进页面先读一次快照,之后以 WS 的 `presence` 事件为准
17 // (事件回发给本人,用于跨标签页镜像)。事件先到时不回填旧快照。
18 const [presence, setPresence] = useState<PresenceState | undefined>(undefined)
19 const presenceFromEvent = useRef(false)
20 const root = useRef<HTMLDivElement>(null)
21 const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
22 const inCall = session.roomId !== null && session.status !== 'idle'
23 // In a call the two toggles drive the live tracks; outside one the microphone
24 // button holds the state the next join starts from and the deafen flag waits
25 // for the connect.
26 const micOn = inCall ? session.micEnabled : !session.micMuted
27 const micTitle =
28 inCall && !session.canPublish ? t('chat.voice.noPublish') : micOn ? t('shell.user.mute') : t('shell.user.unmute')
29
30 useEffect(() => () => clearTimeout(copyTimer.current), [])
31
32 useEffect(() => {
33 let active = true
34 getUserPresence(me.user.user_id)
35 .then((row) => {
36 if (active && !presenceFromEvent.current) setPresence(row.status)
37 })
38 .catch(() => {
39 // 读不到就维持默认(在线):卡片只是展示,不值得打扰用户。
40 })
41 return () => {
42 active = false
43 }
44 }, [me.user.user_id])
45
46 useWsUserEvents((event) => {
47 if (event.kind === 'presence') {
48 presenceFromEvent.current = true
49 setPresence(event.status)
50 }
51 })
52
53 useEffect(() => {
54 if (!cardOpen) return
55 const onDown = (event: PointerEvent) => {
56 if (!root.current?.contains(event.target as Node)) setCardOpen(false)
57 }
58 const onKey = (event: KeyboardEvent) => {
59 if (event.key === 'Escape') setCardOpen(false)
60 }
61 window.addEventListener('pointerdown', onDown)
62 window.addEventListener('keydown', onKey)
63 return () => {
64 window.removeEventListener('pointerdown', onDown)
65 window.removeEventListener('keydown', onKey)
66 }
67 }, [cardOpen])
68
69 const name = me.user.display_name || me.user.username
70 const status = presence ?? 'Active'
71 const statusColor = {
72 Active: '#23a55a',
73 Away: '#f0b232',
74 Dnd: '#da3e4c',
75 Offline: '#80848e',
76 }[status]
77 const statusLabel = {
78 Active: t('label.presence.online'),
79 Away: t('label.presence.away'),
80 Dnd: t('label.presence.busy'),
81 Offline: t('label.presence.offline'),
82 }[status]
83
84 async function copyText(value: string) {
85 try {
86 await navigator.clipboard.writeText(value)
87 } catch {
88 // Clipboard access can be denied; the label still confirms the attempt.
89 }
90 setCopied(true)
91 clearTimeout(copyTimer.current)
92 copyTimer.current = setTimeout(() => setCopied(false), 1600)
93 }
94
95 return (
96 <div ref={root} className="relative flex min-w-0 flex-1 items-center gap-2.5">
97 {cardOpen ? (
98 <div
99 role="dialog"
100 aria-label={t('shell.user.card')}
101 className="absolute -left-4 -right-4 bottom-full z-40 mb-3 overflow-hidden rounded-xl bg-shell-surface shadow-[0_16px_44px_rgb(24_28_31_/_16%)]"
102 >
103 <div className="h-[68px] bg-[linear-gradient(135deg,var(--shell-action),color-mix(in_srgb,var(--shell-action)_45%,var(--shell-tile)))]" />
104 <div className="px-4 pb-4">
105 <span className="relative -mt-8 mb-2 block h-16 w-16">
106 {me.user.avatar_url ? (
107 <img
108 src={me.user.avatar_url}
109 alt=""
110 className="h-16 w-16 rounded-full border-[3px] border-shell-surface bg-shell-tile object-cover"
111 />
112 ) : (
113 <span className="flex h-16 w-16 items-center justify-center rounded-full border-[3px] border-shell-surface bg-shell-tile text-xl font-semibold text-shell-ink">
114 {initial(name)}
115 </span>
116 )}
117 <span
118 className="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border-[3px] border-shell-surface"
119 style={{ backgroundColor: statusColor }}
120 />
121 </span>
122 <p className="truncate text-[17px] font-semibold leading-tight">{name}</p>
123 <span className="mt-0.5 flex min-w-0 items-center gap-1">
124 <span className="truncate text-[13px] text-shell-ink-muted">@{me.user.username}</span>
125 <button
126 type="button"
127 title={t('shell.user.copyUsername')}
128 aria-label={t('shell.user.copyUsername')}
129 onClick={() => void copyText(me.user.username)}
130 className="grid h-5 w-5 shrink-0 place-items-center rounded text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink"
131 >
132 <Copy className="h-3.5 w-3.5" aria-hidden />
133 </button>
134 </span>
135 <div className="mt-3 overflow-hidden rounded-lg bg-shell-field">
136 <div className="flex items-center gap-2 px-3 py-2 text-[13px]">
137 <span
138 className="h-2.5 w-2.5 shrink-0 rounded-full"
139 style={{ backgroundColor: statusColor }}
140 aria-hidden
141 />
142 {statusLabel}
143 </div>
144 <button
145 type="button"
146 onClick={() => void copyText(String(me.user.user_id))}
147 className="flex w-full items-center gap-2 px-3 py-2 text-left text-[13px] transition-colors hover:bg-shell-active"
148 >
149 {copied ? (
150 <Check className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
151 ) : (
152 <Copy className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
153 )}
154 {copied ? t('shell.user.copied') : t('shell.user.copyId')}
155 </button>
156 </div>
157 {onSettings ? (
158 <button
159 type="button"
160 onClick={() => {
161 setCardOpen(false)
162 onSettings()
163 }}
164 className="mt-3 flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-[13px] font-medium text-primary-foreground transition-[filter] hover:brightness-95"
165 >
166 <Pencil className="h-3.5 w-3.5" aria-hidden />
167 {t('shell.user.editProfile')}
168 </button>
169 ) : null}
170 </div>
171 </div>
172 ) : null}
173 <button
174 type="button"
175 aria-haspopup="dialog"
176 aria-expanded={cardOpen}
177 aria-label={t('shell.user.openCard')}
178 onClick={() => setCardOpen((value) => !value)}
179 className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-1 py-1 text-left transition-colors hover:bg-shell-active"
180 >
181 {me.user.avatar_url ? (
182 <img src={me.user.avatar_url} alt="" className="h-9 w-9 shrink-0 rounded-full bg-shell-tile object-cover" />
183 ) : (
184 <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-shell-tile text-sm font-semibold text-shell-ink">
185 {initial(name)}
186 </span>
187 )}
188 <span className="min-w-0 flex-1">
189 <span className="block truncate text-[14px] font-semibold leading-tight">{name}</span>
190 <span className="block truncate text-[12px] leading-tight text-shell-ink-muted">@{me.user.username}</span>
191 </span>
192 </button>
193 <div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
194 <button
195 type="button"
196 aria-label={t('shell.user.mic')}
197 aria-pressed={!micOn}
198 title={micTitle}
199 disabled={inCall && !session.canPublish}
200 onClick={() =>
201 inCall ? void voiceSession.setMicrophone(!session.micEnabled) : voiceSession.setMicMuted(!session.micMuted)
202 }
203 className={['workspace-icon-button is-compact', micOn ? '' : 'is-off'].filter(Boolean).join(' ')}
204 >
205 {micOn ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
206 </button>
207 <button
208 type="button"
209 aria-label={t('shell.user.headphones')}
210 aria-pressed={session.deafened}
211 title={session.deafened ? t('shell.user.undeafen') : t('shell.user.deafen')}
212 onClick={() => void voiceSession.setDeafened(!session.deafened)}
213 className={['workspace-icon-button is-compact', session.deafened ? 'is-off' : ''].filter(Boolean).join(' ')}
214 >
215 {session.deafened ? <HeadphoneOff className="h-4 w-4" /> : <Headphones className="h-4 w-4" />}
216 </button>
217 <button
218 type="button"
219 aria-label={t('shell.user.settings')}
220 title={t('shell.user.settings')}
221 onClick={onSettings}
222 className="workspace-icon-button is-compact"
223 >
224 <Settings className="h-4 w-4" />
225 </button>
226 </div>
227 </div>
228 )
229}
Added view/app/src/pages/workspace/ui/workspace-shell.tsx view/app/src/pages/workspace/ui/workspace-shell.tsx +386 −0 View view/app/src/pages/workspace/ui/workspace-shell.tsx
@@ -0,0 +1,386 @@
1import { AppShell } from '@app/blocks'
2import { RoomWatcher, VoiceCallBar } from '@app/domains/chat'
3import { NotifyNavItem, NotifyToaster, NotifyView } from '@app/domains/notifications'
4import { UserSettingsModal } from '@app/domains/settings'
5import { ExploreView } from '@app/domains/space/discovery'
6import { CreateSpaceModal, SpaceHeader, SpaceSettingsModal } from '@app/domains/space/management'
7import { useT } from '@app/i18n'
8import { ApiFailure } from '@app/lib'
9import { Loader2 } from 'lucide-react'
10import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
12import { prefetchLikelyTargets } from '../model/prefetch'
13import { useWorkspaceData } from '../model/workspace-data'
14import { useShellRoute } from '../model/workspace-route'
15import { BoardView } from './board/board-view'
16import { ChatView } from './chat/chat-view'
17import { ChatLayout } from './layout/chat-layout'
18import { MembersView } from './members-view'
19import { PendingPanel } from './pending-panel'
20import { RepoView } from './repo/repo-view'
21import { SidebarNav } from './sidebar-nav'
22import { SpaceRail } from './space-rail'
23import { UserPanel } from './user-panel'
24
25/**
26 * Route-level layout for the three main interfaces: loads the workspace's
27 * navigation data, fills the shared layout's sidebar slots, and picks the
28 * content column from the route section.
29 */
30export function WorkspaceShell() {
31 const { t } = useT()
32 const route = useShellRoute()
33 const navigate = useNavigate()
34 // ?settings=<section> deep-links a settings section (the join-request
35 // notification points here); the param is consumed on open so a later
36 // manual open starts from the default section again.
37 const [searchParams, setSearchParams] = useSearchParams()
38 const { mode, spaceName, roomId, boardId, repoName } = route
39 const exploring = mode === 'explore'
40 const [creatingSpace, setCreatingSpace] = useState(false)
41 const [spaceReload, setSpaceReload] = useState(0)
42 const [listsReload, setListsReload] = useState(0)
43 const [meReload, setMeReload] = useState(0)
44 const [settingsOpen, setSettingsOpen] = useState(false)
45 const [spaceSettingsOpen, setSpaceSettingsOpen] = useState(false)
46 // Section a deep link asked for; kept until the overlay closes so the
47 // consumed URL param does not race the modal's first open.
48 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
49 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
50 const { me, spaces, rooms, dirs, dms, repos, boards, room, dm, board } = useWorkspaceData(
51 route,
52 spaceReload,
53 listsReload,
54 meReload,
55 )
56 const requestedSection = searchParams.get('settings')
57 const consumeSectionParam = useCallback(() => {
58 if (!searchParams.has('settings')) return
59 const next = new URLSearchParams(searchParams)
60 next.delete('settings')
61 setSearchParams(next, { replace: true })
62 }, [searchParams, setSearchParams])
63 const currentMySpace = spaces.data?.find(({ space }) => space.name === spaceName)
64 // The inbox needs to know which spaces this account is already in, so an
65 // invite you accepted elsewhere reads as settled instead of offering a
66 // button that can only fail. `my_spaces` is already loaded for the rail.
67 const memberSpaceSlugs = useMemo(
68 () => new Set((spaces.data ?? []).map(({ space }) => space.lower_name)),
69 [spaces.data],
70 )
71
72 // A read marker moves while the reader scrolls, so the badge refresh behind it
73 // is coalesced: one list refetch per browse, not one per viewport change.
74 const refreshBadges = useCallback(() => {
75 if (badgeTimer.current) clearTimeout(badgeTimer.current)
76 badgeTimer.current = setTimeout(() => {
77 badgeTimer.current = undefined
78 setListsReload((key) => key + 1)
79 }, 1_200)
80 }, [])
81 useEffect(
82 () => () => {
83 if (badgeTimer.current) clearTimeout(badgeTimer.current)
84 },
85 [],
86 )
87
88 // Idle top-N warm-up: once this space's lists are on screen, prefetch the
89 // rows most likely to be clicked next. Once per space per shell session —
90 // the prefetch layer's own freshness window throttles any repeat.
91 const idleWarmed = useRef<string | null>(null)
92 useEffect(() => {
93 if (exploring || spaceName === '@me' || idleWarmed.current === spaceName) return
94 if (rooms.loading || repos.loading || boards.loading) return
95 if (!rooms.data || !repos.data || !boards.data) return
96 idleWarmed.current = spaceName
97 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data)
98 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
99
100 // A `?settings=<section>` link (join-request notifications) opens the
101 // overlay for the space in scope. Without the permission that owns the
102 // section the overlay would open on an empty column, so the param is
103 // consumed either way and only a permitted deep link opens anything.
104 useEffect(() => {
105 if (!requestedSection || spaceName === '@me' || !currentMySpace) return
106 const canSee = requestedSection === 'join-requests' ? currentMySpace.permissions.includes('manage_members') : true
107 // 深链是外部输入(URL 参数):满足权限时打开设置面板,然后消费掉参数。
108 if (canSee) {
109 /* oxlint-disable react/set-state-in-effect -- URL 参数同步,不是可派生的渲染状态 */
110 setSettingsSection(requestedSection)
111 setSpaceSettingsOpen(true)
112 /* oxlint-enable react/set-state-in-effect */
113 }
114 consumeSectionParam()
115 }, [requestedSection, spaceName, currentMySpace, consumeSectionParam])
116 // Browser tab title mirrors the active section. Derived from loaded data so
117 // it stays correct across language switches (`t` is locale-bound) and across
118 // in-app navigation, which never reloads the document.
119 useEffect(() => {
120 const base = t('shell.title')
121 document.title =
122 mode === 'explore'
123 ? t('space.discover.docTitle')
124 : mode === 'repo' && repoName
125 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
126 : mode === 'board' && boardId
127 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
128 : roomId
129 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
130 : spaceName && spaceName !== '@me'
131 ? t('shell.titleSpace', { space: spaceName })
132 : base
133 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
134
135 if (me.error instanceof ApiFailure && me.error.code === 401) {
136 return <Navigate to="/auth/login" replace />
137 }
138
139 // Frame gate covers the FIRST load only (no data yet). Once the shell has
140 // painted, later refetches (settings saves, space mutations) update the
141 // affected regions in place — `useAsync` keeps the previous data while
142 // loading, so nothing unmounts and the page never flashes back to a loader.
143 const firstFramePending = (!me.data && me.loading) || (!spaces.data && spaces.loading)
144 if (firstFramePending) {
145 return <ShellLoader />
146 }
147
148 const personal = spaceName === '@me'
149 const canOrganize = currentMySpace?.permissions?.includes('manage_channels') ?? false
150 const shell = {
151 // Always the real rail: fabricated placeholder tiles must not flash
152 // while `GET /me/spaces` is still in flight.
153 rail: (
154 <SpaceRail
155 spaces={spaces.data ?? []}
156 activeName={personal ? undefined : spaceName || undefined}
157 discovering={exploring}
158 onAddSpace={() => setCreatingSpace(true)}
159 />
160 ),
161 sideHeader: currentMySpace ? (
162 <SpaceHeader
163 key={`${currentMySpace.space.id}:${spaceReload}`}
164 mySpace={currentMySpace}
165 viewerId={me.data?.user.user_id}
166 onListsChanged={() => setListsReload((key) => key + 1)}
167 onOpenSettings={() => setSpaceSettingsOpen(true)}
168 onLeft={() => {
169 setSpaceReload((key) => key + 1)
170 navigate('/app/@me')
171 }}
172 />
173 ) : undefined,
174 // The personal inbox floats above the "工作区" caption, outside the
175 // draggable channel list; only the @me scope shows it.
176 navPinned: personal ? <NotifyNavItem /> : undefined,
177 nav: spaceName ? (
178 <SidebarNav
179 key={spaceName}
180 spaceName={spaceName}
181 rooms={rooms.data ?? []}
182 dirs={dirs.data ?? []}
183 dms={dms.data ?? []}
184 repos={repos.data ?? []}
185 boards={boards.data ?? []}
186 activeRoomId={roomId}
187 activeBoardId={boardId}
188 activeRepoName={repoName}
189 canOrganize={canOrganize}
190 onChanged={() => setListsReload((key) => key + 1)}
191 />
192 ) : undefined,
193 sideFooter: me.data ? <UserPanel me={me.data} onSettings={() => setSettingsOpen(true)} /> : undefined,
194 }
195
196 const roomWatcher = (
197 <RoomWatcher
198 spaceName={spaceName}
199 rooms={(rooms.data ?? []).map((item) => ({ id: item.id, name: item.name }))}
200 dms={(dms.data ?? []).map((item) => ({ id: item.id, name: item.name }))}
201 activeRoomId={roomId ?? null}
202 />
203 )
204
205 const settingsModal = me.data ? (
206 <UserSettingsModal
207 open={settingsOpen}
208 onClose={() => setSettingsOpen(false)}
209 me={me.data}
210 onMeChanged={() => setMeReload((key) => key + 1)}
211 />
212 ) : null
213
214 // The overlay renders beside the shell rather than inside the sidebar: the
215 // mobile drawer keeps a `transform` on its column, which would otherwise
216 // become the containing block for this `position: fixed` dialog.
217 const spaceSettingsModal = currentMySpace ? (
218 <SpaceSettingsModal
219 open={spaceSettingsOpen}
220 onClose={() => {
221 setSpaceSettingsOpen(false)
222 setSettingsSection(undefined)
223 }}
224 initialSection={settingsSection}
225 mySpace={currentMySpace}
226 viewerId={me.data?.user.user_id}
227 onSpaceUpdated={() => setSpaceReload((key) => key + 1)}
228 onDeleted={() => {
229 setSpaceSettingsOpen(false)
230 setSpaceReload((key) => key + 1)
231 navigate('/app/@me')
232 }}
233 />
234 ) : null
235
236 const createSpaceModal = (
237 <CreateSpaceModal
238 open={creatingSpace}
239 onClose={() => setCreatingSpace(false)}
240 onCreated={(name) => {
241 setCreatingSpace(false)
242 setSpaceReload((key) => key + 1)
243 navigate(`/app/${encodeURIComponent(name)}`)
244 }}
245 />
246 )
247
248 if (mode === 'explore') {
249 return (
250 <>
251 <AppShell {...shell} railOnly>
252 <ExploreView onAddSpace={() => setCreatingSpace(true)} />
253 </AppShell>
254 {createSpaceModal}
255 {settingsModal}
256 {roomWatcher}
257 <NotifyToaster />
258
259 <VoiceCallBar />
260 </>
261 )
262 }
263 if (mode === 'notify') {
264 return (
265 <>
266 <ChatLayout {...shell}>
267 <NotifyView memberSpaceSlugs={memberSpaceSlugs} />
268 </ChatLayout>
269 {createSpaceModal}
270 {settingsModal}
271 {spaceSettingsModal}
272 {roomWatcher}
273 <NotifyToaster />
274
275 <VoiceCallBar />
276 </>
277 )
278 }
279
280 if (mode === 'board') {
281 return (
282 <>
283 <BoardView
284 spaceName={spaceName}
285 boardId={boardId ?? ''}
286 boardHint={board}
287 viewerId={me.data?.user.user_id}
288 shell={shell}
289 onCountsChanged={() => setListsReload((key) => key + 1)}
290 />
291 {createSpaceModal}
292 {settingsModal}
293 {spaceSettingsModal}
294 {roomWatcher}
295 <NotifyToaster />
296
297 <VoiceCallBar />
298 </>
299 )
300 }
301
302 if (mode === 'repo') {
303 return (
304 <>
305 <RepoView
306 spaceName={spaceName}
307 repoName={repoName ?? ''}
308 mySpace={currentMySpace}
309 viewerId={me.data?.user.user_id}
310 shell={shell}
311 />
312 {createSpaceModal}
313 {settingsModal}
314 {spaceSettingsModal}
315 {roomWatcher}
316 <NotifyToaster />
317
318 <VoiceCallBar />
319 </>
320 )
321 }
322
323 if (mode === 'members') {
324 return (
325 <>
326 <MembersView spaceName={spaceName} shell={shell} />
327 {createSpaceModal}
328 {settingsModal}
329 {spaceSettingsModal}
330 {roomWatcher}
331 <NotifyToaster />
332
333 <VoiceCallBar />
334 </>
335 )
336 }
337
338 if (roomId) {
339 return (
340 <>
341 <ChatView
342 spaceName={spaceName}
343 roomId={roomId}
344 viewerId={me.data?.user.user_id}
345 roomHint={room}
346 dmHint={dm}
347 canManageSpace={canOrganize}
348 shell={shell}
349 onBadgesChanged={refreshBadges}
350 />
351 {createSpaceModal}
352 {settingsModal}
353 {spaceSettingsModal}
354 {roomWatcher}
355 <NotifyToaster />
356
357 <VoiceCallBar />
358 </>
359 )
360 }
361
362 // Chat section without a channel: the frame is real, only the column is empty.
363 return (
364 <>
365 <ChatLayout {...shell}>
366 <PendingPanel label={t('shell.pending.pickChannel')} />
367 </ChatLayout>
368 {createSpaceModal}
369 {settingsModal}
370 {spaceSettingsModal}
371 <NotifyToaster />
372
373 <VoiceCallBar />
374 </>
375 )
376}
377
378/** Bare waiting screen shown while the shell's frame data is in flight. */
379function ShellLoader() {
380 const { t } = useT()
381 return (
382 <div className="flex h-dvh w-full items-center justify-center bg-shell-surface">
383 <Loader2 className="h-6 w-6 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
384 </div>
385 )
386}
Added view/app/src/pages/workspace/index.ts view/app/src/pages/workspace/index.ts +6 −0 View view/app/src/pages/workspace/index.ts
@@ -0,0 +1,6 @@
1// Public API of the pages/workspace slice — the route-level workspace page that
2// every /app/:spaceName route renders. Regions follow design/layout.pen.
3// Consumers import from '@app/pages/workspace', never from deeper files.
4
5export { prefetchWorkspaceBootstrap } from './model/prefetch'
6export { WorkspaceShell } from './ui/workspace-shell'