Skip to content

Commit c66dc2f

Browse files
committed
fix:修复工作区互串显示,还有时间乱码
1 parent 3af62ef commit c66dc2f

5 files changed

Lines changed: 267 additions & 32 deletions

File tree

web/src/components/layout/AppLayout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import ToastContainer from '@/components/ui/ToastContainer'
88
import { ErrorBoundary } from '@/components/ErrorBoundary'
99
import { useUIStore } from '@/stores/useUIStore'
1010
import { useSessionStore } from '@/stores/useSessionStore'
11+
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
1112

1213
interface AppLayoutProps {
1314
shellMode?: 'electron' | 'browser'
@@ -53,6 +54,7 @@ export default function AppLayout({ shellMode = 'electron' }: AppLayoutProps) {
5354
const fileTreePanelOpen = useUIStore((s) => s.fileTreePanelOpen)
5455
const fileTreePanelWidth = useUIStore((s) => s.fileTreePanelWidth)
5556
const setFileTreePanelWidth = useUIStore((s) => s.setFileTreePanelWidth)
57+
const currentWorkspaceHash = useWorkspaceStore((s) => s.currentWorkspaceHash)
5658

5759
const sidebarResize = useResize(useCallback((delta) => {
5860
setSidebarWidth(Math.max(180, Math.min(500, sidebarWidth + delta)))
@@ -113,7 +115,7 @@ export default function AppLayout({ shellMode = 'electron' }: AppLayoutProps) {
113115
{fileTreePanelOpen && (
114116
<div className="right-panel" style={{ width: fileTreePanelWidth, position: 'relative' }}>
115117
<div className="resize-handle resize-handle-left" onMouseDown={fileTreeResize.onMouseDown} />
116-
<FileTreePanel />
118+
<FileTreePanel key={`file-tree:${currentWorkspaceHash || 'default'}`} />
117119
</div>
118120
)}
119121
</div>

web/src/components/panels/FileTreePanel.test.tsx

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2-
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
33
import FileTreePanel from './FileTreePanel'
44
import { useUIStore } from '@/stores/useUIStore'
55
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
@@ -27,7 +27,10 @@ describe('FileTreePanel', () => {
2727
} as never)
2828
useWorkspaceStore.setState({
2929
currentWorkspaceHash: 'ws-1',
30-
workspaces: [{ hash: 'ws-1', name: 'demo', path: 'D:/demo', createdAt: '', updatedAt: '' }],
30+
workspaces: [
31+
{ hash: 'ws-1', name: 'demo-1', path: 'D:/demo-1', createdAt: '', updatedAt: '' },
32+
{ hash: 'ws-2', name: 'demo-2', path: 'D:/demo-2', createdAt: '', updatedAt: '' },
33+
],
3134
} as never)
3235
})
3336

@@ -121,4 +124,140 @@ describe('FileTreePanel', () => {
121124
const scrollArea = screen.getByTestId('file-tree-scroll-area')
122125
expect(scrollArea).toHaveStyle({ overflowY: 'auto', minHeight: '0px', flex: '1 1 0%' })
123126
})
127+
128+
it('reloads the root tree when the workspace changes', async () => {
129+
mockGatewayAPI = {
130+
listFiles: vi.fn().mockImplementation(async ({ path = '' }: { path?: string }) => {
131+
const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash
132+
if (path) {
133+
return { payload: { files: [] } }
134+
}
135+
if (workspaceHash === 'ws-1') {
136+
return {
137+
payload: {
138+
files: [{ name: 'old.go', path: 'old.go', is_dir: false }],
139+
},
140+
}
141+
}
142+
return {
143+
payload: {
144+
files: [{ name: 'new.ts', path: 'new.ts', is_dir: false }],
145+
},
146+
}
147+
}),
148+
readFile: vi.fn(),
149+
}
150+
151+
render(<FileTreePanel />)
152+
153+
await waitFor(() => {
154+
expect(screen.getByText('old.go')).toBeTruthy()
155+
})
156+
157+
act(() => {
158+
useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never)
159+
})
160+
161+
await waitFor(() => {
162+
expect(screen.getByText('new.ts')).toBeTruthy()
163+
})
164+
expect(screen.queryByText('old.go')).toBeNull()
165+
})
166+
167+
it('does not keep expanded directory state across workspace switches', async () => {
168+
mockGatewayAPI = {
169+
listFiles: vi.fn().mockImplementation(async ({ path = '' }: { path?: string }) => {
170+
const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash
171+
if (!path) {
172+
return {
173+
payload: {
174+
files: [{ name: 'src', path: 'src', is_dir: true }],
175+
},
176+
}
177+
}
178+
179+
if (workspaceHash === 'ws-1') {
180+
return {
181+
payload: {
182+
files: [{ name: 'old.txt', path: 'src/old.txt', is_dir: false }],
183+
},
184+
}
185+
}
186+
return {
187+
payload: {
188+
files: [{ name: 'new.txt', path: 'src/new.txt', is_dir: false }],
189+
},
190+
}
191+
}),
192+
readFile: vi.fn(),
193+
}
194+
195+
render(<FileTreePanel />)
196+
197+
await waitFor(() => {
198+
expect(screen.getByText('src')).toBeTruthy()
199+
})
200+
201+
fireEvent.click(screen.getByText('src'))
202+
203+
await waitFor(() => {
204+
expect(screen.getByText('old.txt')).toBeTruthy()
205+
})
206+
207+
act(() => {
208+
useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never)
209+
})
210+
211+
await waitFor(() => {
212+
expect(screen.getByText('src')).toBeTruthy()
213+
})
214+
expect(screen.queryByText('old.txt')).toBeNull()
215+
expect(screen.queryByText('new.txt')).toBeNull()
216+
})
217+
218+
it('ignores late root responses from the previous workspace', async () => {
219+
let resolveOldRoot: ((value: { payload: { files: Array<{ name: string; path: string; is_dir: boolean }> } }) => void) | null = null
220+
221+
mockGatewayAPI = {
222+
listFiles: vi.fn().mockImplementation(({ path = '' }: { path?: string }) => {
223+
const workspaceHash = useWorkspaceStore.getState().currentWorkspaceHash
224+
if (path) {
225+
return Promise.resolve({ payload: { files: [] } })
226+
}
227+
if (workspaceHash === 'ws-1') {
228+
return new Promise((resolve) => {
229+
resolveOldRoot = resolve
230+
})
231+
}
232+
return Promise.resolve({
233+
payload: {
234+
files: [{ name: 'new.ts', path: 'new.ts', is_dir: false }],
235+
},
236+
})
237+
}),
238+
readFile: vi.fn(),
239+
}
240+
241+
render(<FileTreePanel />)
242+
243+
act(() => {
244+
useWorkspaceStore.setState({ currentWorkspaceHash: 'ws-2' } as never)
245+
})
246+
247+
await waitFor(() => {
248+
expect(screen.getByText('new.ts')).toBeTruthy()
249+
})
250+
251+
await act(async () => {
252+
resolveOldRoot?.({
253+
payload: {
254+
files: [{ name: 'old.go', path: 'old.go', is_dir: false }],
255+
},
256+
})
257+
await Promise.resolve()
258+
})
259+
260+
expect(screen.getByText('new.ts')).toBeTruthy()
261+
expect(screen.queryByText('old.go')).toBeNull()
262+
})
124263
})

web/src/components/panels/FileTreePanel.tsx

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback } from 'react'
1+
import { useState, useEffect, useCallback, useRef } from 'react'
22
import { useUIStore, type FilePreviewTab } from '@/stores/useUIStore'
33
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
44
import { useGatewayAPI } from '@/context/RuntimeProvider'
@@ -38,15 +38,15 @@ function buildFileTree(entries: FileEntry[]): FileTreeNode[] {
3838
const rootNodes: FileTreeNode[] = []
3939
const dirMap = new Map<string, FileTreeNode>()
4040

41-
// 先创建所有目录节点
41+
// 先创建所有目录节点
4242
for (const entry of entries) {
4343
if (entry.is_dir) {
4444
const node: FileTreeNode = { entry, children: [] }
4545
dirMap.set(entry.path, node)
4646
}
4747
}
4848

49-
// 再分配所有节点到父目录
49+
// 再把所有节点归到父目录下。
5050
for (const entry of entries) {
5151
const parentPath = entry.path.split('/').slice(0, -1).join('/')
5252
if (parentPath && dirMap.has(parentPath)) {
@@ -57,14 +57,14 @@ function buildFileTree(entries: FileEntry[]): FileTreeNode[] {
5757
parent.children!.push({ entry })
5858
}
5959
} else if (!parentPath) {
60-
// 根级别节点
60+
// 根级节点。
6161
if (entry.is_dir) {
6262
rootNodes.push(dirMap.get(entry.path)!)
6363
} else {
6464
rootNodes.push({ entry })
6565
}
6666
} else {
67-
// 父目录缺失:作为根节点挂载(容错处理,避免节点丢失)
67+
// 父目录缺失时挂到根级,避免节点被丢弃。
6868
if (entry.is_dir) {
6969
rootNodes.push(dirMap.get(entry.path)!)
7070
} else {
@@ -99,6 +99,7 @@ function FileTreeItem({ node, depth = 0, dirCache, onLoadDir, onOpenFile }: File
9999
await onOpenFile(node.entry.path)
100100
return
101101
}
102+
102103
if (!isLoaded) {
103104
setLocalLoading(true)
104105
try {
@@ -190,13 +191,36 @@ export default function FileTreePanel() {
190191
const [loading, setLoading] = useState(false)
191192
const [error, setError] = useState('')
192193
const [currentPath, setCurrentPath] = useState('')
194+
const activeWorkspaceRef = useRef(currentWorkspaceHash)
195+
const rootRequestTokenRef = useRef(0)
196+
const mountedRef = useRef(true)
197+
198+
useEffect(() => {
199+
return () => {
200+
mountedRef.current = false
201+
}
202+
}, [])
203+
204+
useEffect(() => {
205+
activeWorkspaceRef.current = currentWorkspaceHash
206+
}, [currentWorkspaceHash])
193207

194208
const loadDir = useCallback(async (path: string) => {
195209
if (!gatewayAPI) return
210+
211+
const requestWorkspaceHash = activeWorkspaceRef.current
212+
196213
try {
197214
const result = await gatewayAPI.listFiles({ path })
215+
if (!mountedRef.current || activeWorkspaceRef.current !== requestWorkspaceHash) {
216+
return
217+
}
218+
198219
const nodes = buildFileTree(result.payload.files)
199220
setDirCache((prev) => {
221+
if (!mountedRef.current || activeWorkspaceRef.current !== requestWorkspaceHash) {
222+
return prev
223+
}
200224
const next = new Map(prev)
201225
next.set(path, nodes)
202226
return next
@@ -207,24 +231,55 @@ export default function FileTreePanel() {
207231
}
208232
}, [gatewayAPI])
209233

210-
const loadRoot = useCallback(async () => {
234+
// loadRoot 在工作区切换时重置文件树状态,并丢弃旧工作区的晚到响应。
235+
const loadRoot = useCallback(async (workspaceHash: string) => {
211236
if (!gatewayAPI) return
237+
238+
const requestToken = rootRequestTokenRef.current + 1
239+
rootRequestTokenRef.current = requestToken
240+
241+
setRootNodes([])
242+
setDirCache(new Map())
243+
setCurrentPath('')
212244
setLoading(true)
213245
setError('')
246+
214247
try {
215248
const result = await gatewayAPI.listFiles({ path: '' })
249+
if (
250+
!mountedRef.current ||
251+
activeWorkspaceRef.current !== workspaceHash ||
252+
rootRequestTokenRef.current !== requestToken
253+
) {
254+
return
255+
}
256+
216257
setRootNodes(buildFileTree(result.payload.files))
217258
setCurrentPath('')
218259
} catch (err) {
260+
if (
261+
!mountedRef.current ||
262+
activeWorkspaceRef.current !== workspaceHash ||
263+
rootRequestTokenRef.current !== requestToken
264+
) {
265+
return
266+
}
267+
219268
const msg = err instanceof Error ? err.message : 'Failed to load file list'
220269
setError(msg)
221270
console.error('listFiles failed:', err)
222271
} finally {
223-
setLoading(false)
272+
if (
273+
mountedRef.current &&
274+
activeWorkspaceRef.current === workspaceHash &&
275+
rootRequestTokenRef.current === requestToken
276+
) {
277+
setLoading(false)
278+
}
224279
}
225280
}, [gatewayAPI])
226281

227-
// openFilePreview 负责复用/创建文件标签,并按需拉取只读预览内容。
282+
// openFilePreview 负责复用或创建文件标签,并按需拉取只读预览内容。
228283
const openFilePreview = useCallback(async (path: string) => {
229284
if (!gatewayAPI) return
230285

@@ -248,8 +303,9 @@ export default function FileTreePanel() {
248303
}, [gatewayAPI, openPreviewTab, setPreviewTabContent, setPreviewTabError, setPreviewTabLoading])
249304

250305
useEffect(() => {
251-
loadRoot()
252-
}, [loadRoot])
306+
activeWorkspaceRef.current = currentWorkspaceHash
307+
void loadRoot(currentWorkspaceHash)
308+
}, [currentWorkspaceHash, loadRoot])
253309

254310
return (
255311
<div style={styles.container}>

web/src/utils/format.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { relativeTime } from './format'
3+
4+
describe('relativeTime', () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers()
7+
vi.setSystemTime(new Date('2026-05-09T12:00:00Z'))
8+
})
9+
10+
afterEach(() => {
11+
vi.useRealTimers()
12+
})
13+
14+
it('returns empty string for empty input', () => {
15+
expect(relativeTime('')).toBe('')
16+
})
17+
18+
it('returns the original value for invalid timestamps', () => {
19+
expect(relativeTime('not-a-time')).toBe('not-a-time')
20+
})
21+
22+
it('returns 刚刚 for times within one minute', () => {
23+
expect(relativeTime('2026-05-09T11:59:30Z')).toBe('刚刚')
24+
})
25+
26+
it('returns minute, hour, day, and month suffixes for older timestamps', () => {
27+
expect(relativeTime('2026-05-09T11:55:00Z')).toBe('5m')
28+
expect(relativeTime('2026-05-09T09:00:00Z')).toBe('3h')
29+
expect(relativeTime('2026-05-06T12:00:00Z')).toBe('3d')
30+
expect(relativeTime('2026-03-05T12:00:00Z')).toBe('2mo')
31+
})
32+
})

0 commit comments

Comments
 (0)