@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17621
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -141
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -553
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -56
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -773
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -677
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -263
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1295
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/** SessionListView: owned-row filtering, incremental pages, session creation. */
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
|
-
import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
6
|
-
import { SessionListView, type SessionListViewProps } from './SessionListView.tsx'
|
|
7
|
-
import { type SessionView } from './App.tsx'
|
|
8
|
-
|
|
9
|
-
// The api module is fully mocked; the view's App.tsx helpers stay real.
|
|
10
|
-
vi.mock('../api.ts', () => ({
|
|
11
|
-
listSessions: vi.fn(),
|
|
12
|
-
listWorkspaces: vi.fn(),
|
|
13
|
-
listAgentPresets: vi.fn(),
|
|
14
|
-
createSession: vi.fn(),
|
|
15
|
-
}))
|
|
16
|
-
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
17
|
-
|
|
18
|
-
const listSessionsMock = vi.mocked(listSessions)
|
|
19
|
-
const listWorkspacesMock = vi.mocked(listWorkspaces)
|
|
20
|
-
const listAgentPresetsMock = vi.mocked(listAgentPresets)
|
|
21
|
-
const createSessionMock = vi.mocked(createSession)
|
|
22
|
-
|
|
23
|
-
const workspace: WorkspaceRow = {
|
|
24
|
-
workspaceId: 'w-1' as never,
|
|
25
|
-
path: '/tmp/demo',
|
|
26
|
-
title: '演示项目',
|
|
27
|
-
sessionIds: ['s-1'] as never,
|
|
28
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
29
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A session.list summary row. */
|
|
33
|
-
function summary(sessionId: string, updatedAt: number, extra: Record<string, unknown> = {}): never {
|
|
34
|
-
return { sessionId, updatedAt, running: false, blank: false, ...extra } as never
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
let picked: SessionView | undefined
|
|
38
|
-
|
|
39
|
-
function renderList(props: Partial<SessionListViewProps> = {}): void {
|
|
40
|
-
picked = undefined
|
|
41
|
-
render(
|
|
42
|
-
<SessionListView
|
|
43
|
-
workspace={workspace}
|
|
44
|
-
onBack={() => {}}
|
|
45
|
-
onPick={(session) => { picked = session }}
|
|
46
|
-
{...props}
|
|
47
|
-
/>,
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
listSessionsMock.mockResolvedValue({ items: [], hasMore: false })
|
|
53
|
-
listWorkspacesMock.mockResolvedValue([workspace])
|
|
54
|
-
listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false })
|
|
55
|
-
createSessionMock.mockResolvedValue({ sessionId: 's-new' })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
afterEach(() => {
|
|
59
|
-
cleanup()
|
|
60
|
-
vi.clearAllMocks()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
describe('SessionListView roster', () => {
|
|
64
|
-
it('renders the sessions owned by the workspace', async () => {
|
|
65
|
-
listSessionsMock.mockResolvedValue({
|
|
66
|
-
items: [
|
|
67
|
-
summary('s-1', 1_700_000_000_000, { projections: { values: { title: '改造移动端' } } }),
|
|
68
|
-
summary('s-other', 1_600_000_000_000, { cwd: '/tmp/foreign' }),
|
|
69
|
-
],
|
|
70
|
-
hasMore: false,
|
|
71
|
-
})
|
|
72
|
-
renderList()
|
|
73
|
-
|
|
74
|
-
expect(await screen.findByText('改造移动端')).toBeTruthy()
|
|
75
|
-
// The foreign session (not in workspace.sessionIds) stays hidden.
|
|
76
|
-
expect(screen.queryByText(/foreign/)).toBeNull()
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('shows the empty state when the workspace owns no sessions', async () => {
|
|
80
|
-
renderList()
|
|
81
|
-
expect(await screen.findByText(/还没有会话/)).toBeTruthy()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('refreshes the owned-id set from workspace.list on mount', async () => {
|
|
85
|
-
listSessionsMock.mockResolvedValue({
|
|
86
|
-
items: [summary('s-2', 1_700_000_000_000, { cwd: '/tmp/other' })],
|
|
87
|
-
hasMore: false,
|
|
88
|
-
})
|
|
89
|
-
listWorkspacesMock.mockResolvedValue([
|
|
90
|
-
{ ...workspace, sessionIds: ['s-2'] as never },
|
|
91
|
-
])
|
|
92
|
-
renderList()
|
|
93
|
-
// s-2 is absent from the stale row but present in the fresh roster.
|
|
94
|
-
expect(await screen.findByText(/other/)).toBeTruthy()
|
|
95
|
-
})
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
describe('SessionListView creation', () => {
|
|
99
|
-
it('creates a blank session in the workspace and opens it immediately', async () => {
|
|
100
|
-
renderList()
|
|
101
|
-
await screen.findByText(/还没有会话/)
|
|
102
|
-
|
|
103
|
-
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
104
|
-
|
|
105
|
-
await waitFor(() => {
|
|
106
|
-
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
|
|
107
|
-
})
|
|
108
|
-
await waitFor(() => {
|
|
109
|
-
expect(picked).toBeDefined()
|
|
110
|
-
})
|
|
111
|
-
expect(picked?.sessionId).toBe('s-new')
|
|
112
|
-
expect(picked?.blank).toBe(true)
|
|
113
|
-
// The fresh row is prepended without waiting for a refetch.
|
|
114
|
-
expect(await screen.findByText('新会话')).toBeTruthy()
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('creates the session with the selected agent preset', async () => {
|
|
118
|
-
listAgentPresetsMock.mockResolvedValue({
|
|
119
|
-
presets: [
|
|
120
|
-
{ id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
|
|
121
|
-
{ id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
|
|
122
|
-
],
|
|
123
|
-
authorable: false,
|
|
124
|
-
hasDocument: false,
|
|
125
|
-
})
|
|
126
|
-
renderList()
|
|
127
|
-
|
|
128
|
-
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' })
|
|
129
|
-
expect((picker as HTMLSelectElement).value).toBe('router-standard')
|
|
130
|
-
fireEvent.change(picker, { target: { value: 'router-spec' } })
|
|
131
|
-
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
132
|
-
|
|
133
|
-
await waitFor(() => {
|
|
134
|
-
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' })
|
|
135
|
-
})
|
|
136
|
-
expect(await screen.findByText('规格驱动模式')).toBeTruthy()
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('skips a broken default preset and disables it in the picker', async () => {
|
|
140
|
-
listAgentPresetsMock.mockResolvedValue({
|
|
141
|
-
presets: [
|
|
142
|
-
{ id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
|
|
143
|
-
{ id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
|
|
144
|
-
],
|
|
145
|
-
authorable: false,
|
|
146
|
-
hasDocument: false,
|
|
147
|
-
})
|
|
148
|
-
renderList()
|
|
149
|
-
|
|
150
|
-
const picker = await screen.findByRole('combobox', { name: 'Agent 模式' }) as HTMLSelectElement
|
|
151
|
-
expect(picker.value).toBe('local-router')
|
|
152
|
-
expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true)
|
|
153
|
-
expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy()
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('keeps default session creation available when the preset roster fails', async () => {
|
|
157
|
-
listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'))
|
|
158
|
-
renderList()
|
|
159
|
-
await screen.findByText(/还没有会话/)
|
|
160
|
-
|
|
161
|
-
const button = await screen.findByRole('button', { name: '+ 新建会话' })
|
|
162
|
-
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
163
|
-
fireEvent.click(button)
|
|
164
|
-
|
|
165
|
-
await waitFor(() => {
|
|
166
|
-
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' })
|
|
167
|
-
})
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('waits for the preset roster before enabling session creation', async () => {
|
|
171
|
-
let resolvePresets: (value: Awaited<ReturnType<typeof listAgentPresets>>) => void = () => {}
|
|
172
|
-
listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve }))
|
|
173
|
-
renderList()
|
|
174
|
-
|
|
175
|
-
const button = screen.getByRole('button', { name: '+ 新建会话' })
|
|
176
|
-
expect(button.hasAttribute('disabled')).toBe(true)
|
|
177
|
-
|
|
178
|
-
resolvePresets({ presets: [], authorable: false, hasDocument: false })
|
|
179
|
-
await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false) })
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('keeps the button disabled while a creation is in flight', async () => {
|
|
183
|
-
let resolveCreate: (value: { sessionId: string }) => void = () => {}
|
|
184
|
-
createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve }))
|
|
185
|
-
renderList()
|
|
186
|
-
await screen.findByText(/还没有会话/)
|
|
187
|
-
|
|
188
|
-
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
189
|
-
const button = await screen.findByRole('button', { name: '创建中…' })
|
|
190
|
-
expect(button.hasAttribute('disabled')).toBe(true)
|
|
191
|
-
|
|
192
|
-
resolveCreate({ sessionId: 's-new' })
|
|
193
|
-
await waitFor(() => { expect(picked).toBeDefined() })
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('shows the stale-host hint when creation is refused with HTTP 403', async () => {
|
|
197
|
-
createSessionMock.mockRejectedValue(new Error('HTTP 403'))
|
|
198
|
-
renderList()
|
|
199
|
-
await screen.findByText(/还没有会话/)
|
|
200
|
-
|
|
201
|
-
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }))
|
|
202
|
-
|
|
203
|
-
expect(await screen.findByText(/HTTP 403/)).toBeTruthy()
|
|
204
|
-
expect(await screen.findByText(/重启 dsh web/)).toBeTruthy()
|
|
205
|
-
expect(picked).toBeUndefined()
|
|
206
|
-
})
|
|
207
|
-
})
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sessions level: one workspace's sessions, loaded incrementally — the
|
|
3
|
-
* first page fetches session.list with a cursor, scrolling appends further
|
|
4
|
-
* pages (never the whole list at once). Rows are filtered to the opened
|
|
5
|
-
* workspace by its owned session ids; extra pages are pulled on demand so
|
|
6
|
-
* a workspace with many sessions converges without a full transfer.
|
|
7
|
-
*
|
|
8
|
-
* Creating a session is this level's other action: the workspace's id is
|
|
9
|
-
* sent to session.create (the host attaches the new session to it), the
|
|
10
|
-
* fresh row is prepended optimistically, and the user lands straight in
|
|
11
|
-
* the new chat — the same "new session opens" flow as the desktop UI.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
15
|
-
import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
16
|
-
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
|
|
17
|
-
import type { SessionSummary } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
18
|
-
import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.ts'
|
|
19
|
-
import { errorText, formatTime, staleHostHint, toSessionView, type SessionView } from './App.tsx'
|
|
20
|
-
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
21
|
-
|
|
22
|
-
/** Props for the session list. */
|
|
23
|
-
export interface SessionListViewProps {
|
|
24
|
-
workspace: WorkspaceRow
|
|
25
|
-
onBack(): void
|
|
26
|
-
onPick(session: SessionView): void
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Rows that belong to the opened workspace (its owned session id set). */
|
|
30
|
-
function ownedItems(page: SessionSummary[], workspace: WorkspaceRow): SessionView[] {
|
|
31
|
-
const owned = new Set(workspace.sessionIds)
|
|
32
|
-
return page
|
|
33
|
-
.filter(item => owned.has(item.sessionId as never))
|
|
34
|
-
.map(item => toSessionView(item))
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Render one workspace's paged session list.
|
|
39
|
-
* @param props - the workspace, back action, and pick action.
|
|
40
|
-
* @returns the session list.
|
|
41
|
-
*/
|
|
42
|
-
export function SessionListView({ workspace, onBack, onPick }: SessionListViewProps) {
|
|
43
|
-
const [rows, setRows] = useState<SessionView[]>([])
|
|
44
|
-
const [hasMore, setHasMore] = useState(false)
|
|
45
|
-
const [loading, setLoading] = useState(true)
|
|
46
|
-
const [error, setError] = useState<string | undefined>(undefined)
|
|
47
|
-
const [creating, setCreating] = useState(false)
|
|
48
|
-
const [createError, setCreateError] = useState<string | undefined>(undefined)
|
|
49
|
-
const [presets, setPresets] = useState<readonly AgentPresetEntry[]>([])
|
|
50
|
-
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(undefined)
|
|
51
|
-
const [presetsLoading, setPresetsLoading] = useState(true)
|
|
52
|
-
const cursorRef = useRef<string | undefined>(undefined)
|
|
53
|
-
const busyRef = useRef(false)
|
|
54
|
-
// The freshest owned-id set: the mount effect re-reads the workspace roster
|
|
55
|
-
// so a session created since this row was captured still shows; loadMore
|
|
56
|
-
// filters later pages through this ref instead of the stale prop.
|
|
57
|
-
const workspaceRef = useRef(workspace)
|
|
58
|
-
|
|
59
|
-
// First page on mount. The workspace roster is re-read alongside so the
|
|
60
|
-
// owned-id set is fresh: a session created (or attached) since this
|
|
61
|
-
// workspace row was captured must not vanish from the filter.
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
let cancelled = false
|
|
64
|
-
setLoading(true)
|
|
65
|
-
setError(undefined)
|
|
66
|
-
void Promise.all([listSessions(), listWorkspaces()]).then(
|
|
67
|
-
([page, workspaces]) => {
|
|
68
|
-
if (cancelled) return
|
|
69
|
-
const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId)
|
|
70
|
-
const current = fresh ?? workspace
|
|
71
|
-
workspaceRef.current = current
|
|
72
|
-
setRows(ownedItems(page.items, current))
|
|
73
|
-
cursorRef.current = page.nextCursor
|
|
74
|
-
setHasMore(page.hasMore)
|
|
75
|
-
setLoading(false)
|
|
76
|
-
},
|
|
77
|
-
(reason: unknown) => {
|
|
78
|
-
if (cancelled) return
|
|
79
|
-
setError(errorText(reason))
|
|
80
|
-
setLoading(false)
|
|
81
|
-
},
|
|
82
|
-
)
|
|
83
|
-
return () => { cancelled = true }
|
|
84
|
-
}, [workspace])
|
|
85
|
-
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
let cancelled = false
|
|
88
|
-
setPresets([])
|
|
89
|
-
setSelectedPreset(undefined)
|
|
90
|
-
setPresetsLoading(true)
|
|
91
|
-
void listAgentPresets().then(
|
|
92
|
-
(roster) => {
|
|
93
|
-
if (cancelled) return
|
|
94
|
-
const usable = roster.presets.filter(preset => preset.broken === undefined)
|
|
95
|
-
setPresets(roster.presets)
|
|
96
|
-
setSelectedPreset((usable.find(preset => preset.isDefault) ?? usable[0])?.id)
|
|
97
|
-
setPresetsLoading(false)
|
|
98
|
-
},
|
|
99
|
-
() => {
|
|
100
|
-
if (cancelled) return
|
|
101
|
-
setPresetsLoading(false)
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
return () => { cancelled = true }
|
|
105
|
-
}, [workspace])
|
|
106
|
-
|
|
107
|
-
/** Pull the next page and append the rows that belong to this workspace. */
|
|
108
|
-
const loadMore = useCallback(() => {
|
|
109
|
-
if (busyRef.current) return
|
|
110
|
-
const cursor = cursorRef.current
|
|
111
|
-
if (cursor === undefined) return
|
|
112
|
-
busyRef.current = true
|
|
113
|
-
setLoading(true)
|
|
114
|
-
void listSessions(cursor).then(
|
|
115
|
-
(page) => {
|
|
116
|
-
busyRef.current = false
|
|
117
|
-
setLoading(false)
|
|
118
|
-
cursorRef.current = page.nextCursor
|
|
119
|
-
setHasMore(page.hasMore)
|
|
120
|
-
setRows(previous => [...previous, ...ownedItems(page.items, workspaceRef.current)])
|
|
121
|
-
},
|
|
122
|
-
(reason: unknown) => {
|
|
123
|
-
busyRef.current = false
|
|
124
|
-
setLoading(false)
|
|
125
|
-
setError(errorText(reason))
|
|
126
|
-
},
|
|
127
|
-
)
|
|
128
|
-
}, [workspace])
|
|
129
|
-
|
|
130
|
-
/** Create a blank session in this workspace and open it immediately. */
|
|
131
|
-
const handleCreate = useCallback(() => {
|
|
132
|
-
if (creating) return
|
|
133
|
-
setCreating(true)
|
|
134
|
-
setCreateError(undefined)
|
|
135
|
-
void createSession({
|
|
136
|
-
workspaceId: workspace.workspaceId,
|
|
137
|
-
...(selectedPreset !== undefined ? { agentPreset: selectedPreset } : {}),
|
|
138
|
-
}).then(
|
|
139
|
-
(created) => {
|
|
140
|
-
setCreating(false)
|
|
141
|
-
const view: SessionView = {
|
|
142
|
-
sessionId: created.sessionId,
|
|
143
|
-
title: '新会话',
|
|
144
|
-
updatedAt: Date.now(),
|
|
145
|
-
running: false,
|
|
146
|
-
blank: true,
|
|
147
|
-
}
|
|
148
|
-
setRows(previous => [view, ...previous])
|
|
149
|
-
onPick(view)
|
|
150
|
-
},
|
|
151
|
-
(reason: unknown) => {
|
|
152
|
-
setCreating(false)
|
|
153
|
-
setCreateError(errorText(reason))
|
|
154
|
-
},
|
|
155
|
-
)
|
|
156
|
-
}, [creating, workspace, onPick, selectedPreset])
|
|
157
|
-
|
|
158
|
-
const createHint = createError !== undefined ? staleHostHint(createError) : undefined
|
|
159
|
-
const selectedPresetEntry = presets.find(preset => preset.id === selectedPreset)
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<div className="mobile">
|
|
163
|
-
<header className="mobile-header">
|
|
164
|
-
<button type="button" className="mobile-back" aria-label="返回" onClick={onBack}>‹</button>
|
|
165
|
-
<h1 className="mobile-title mobile-titleInline">{workspace.title}</h1>
|
|
166
|
-
<ThemeToggle />
|
|
167
|
-
</header>
|
|
168
|
-
{error !== undefined && <p className="mobile-error mobile-pad">{error}</p>}
|
|
169
|
-
<div className="mobile-create mobile-pad">
|
|
170
|
-
{presets.length > 0 && (
|
|
171
|
-
<label className="mobile-preset">
|
|
172
|
-
<span className="mobile-presetLabel">Agent 模式</span>
|
|
173
|
-
<select
|
|
174
|
-
className="mobile-presetSelect"
|
|
175
|
-
value={selectedPreset ?? ''}
|
|
176
|
-
disabled={selectedPreset === undefined}
|
|
177
|
-
onChange={(event) => { setSelectedPreset(event.target.value) }}
|
|
178
|
-
>
|
|
179
|
-
{selectedPreset === undefined && <option value="">无可用 Agent 模式</option>}
|
|
180
|
-
{presets.map(preset => (
|
|
181
|
-
<option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
|
|
182
|
-
{preset.name ?? preset.id}
|
|
183
|
-
{preset.isDefault ? '(默认)' : ''}
|
|
184
|
-
{preset.trust === 'user' ? '(本地)' : ''}
|
|
185
|
-
{preset.broken !== undefined ? '(不可用)' : ''}
|
|
186
|
-
</option>
|
|
187
|
-
))}
|
|
188
|
-
</select>
|
|
189
|
-
</label>
|
|
190
|
-
)}
|
|
191
|
-
{selectedPresetEntry?.description !== undefined && (
|
|
192
|
-
<p className="mobile-presetDescription">{selectedPresetEntry.description}</p>
|
|
193
|
-
)}
|
|
194
|
-
<button
|
|
195
|
-
type="button"
|
|
196
|
-
className="mobile-new"
|
|
197
|
-
disabled={creating || presetsLoading}
|
|
198
|
-
onClick={() => { void handleCreate() }}
|
|
199
|
-
>
|
|
200
|
-
{creating ? '创建中…' : '+ 新建会话'}
|
|
201
|
-
</button>
|
|
202
|
-
</div>
|
|
203
|
-
{createError !== undefined && (
|
|
204
|
-
<p className="mobile-error mobile-pad">
|
|
205
|
-
{createError}
|
|
206
|
-
{createHint !== undefined && <span className="mobile-hint">{createHint}</span>}
|
|
207
|
-
</p>
|
|
208
|
-
)}
|
|
209
|
-
<ul className="mobile-list">
|
|
210
|
-
{rows.map(row => (
|
|
211
|
-
<li key={row.sessionId}>
|
|
212
|
-
<button type="button" className="mobile-row" onClick={() => { onPick(row) }}>
|
|
213
|
-
<span className="mobile-rowMain">
|
|
214
|
-
<span className="mobile-rowTitle">
|
|
215
|
-
{row.blank ? '新会话' : row.title}
|
|
216
|
-
{row.running ? <span className="mobile-live">●</span> : null}
|
|
217
|
-
</span>
|
|
218
|
-
<span className="mobile-rowMeta">{formatTime(row.updatedAt)}</span>
|
|
219
|
-
</span>
|
|
220
|
-
<span className="mobile-chevron">›</span>
|
|
221
|
-
</button>
|
|
222
|
-
</li>
|
|
223
|
-
))}
|
|
224
|
-
</ul>
|
|
225
|
-
{hasMore && (
|
|
226
|
-
<div className="mobile-pad">
|
|
227
|
-
<button
|
|
228
|
-
type="button"
|
|
229
|
-
className="mobile-button mobile-block"
|
|
230
|
-
disabled={loading}
|
|
231
|
-
onClick={() => { void loadMore() }}
|
|
232
|
-
>
|
|
233
|
-
{loading ? '加载中…' : '加载更多会话'}
|
|
234
|
-
</button>
|
|
235
|
-
</div>
|
|
236
|
-
)}
|
|
237
|
-
{!hasMore && rows.length === 0 && !loading && (
|
|
238
|
-
<div className="mobile-empty">
|
|
239
|
-
<p className="mobile-muted">该工作区还没有会话,点上方按钮新建一个</p>
|
|
240
|
-
</div>
|
|
241
|
-
)}
|
|
242
|
-
</div>
|
|
243
|
-
)
|
|
244
|
-
}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/** Mobile workspace landing: roster rendering and QR deep-link selection. */
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react'
|
|
5
|
-
import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
6
|
-
import { mobileWorkspaceTarget } from './App.tsx'
|
|
7
|
-
import { WorkspaceView } from './WorkspaceView.tsx'
|
|
8
|
-
|
|
9
|
-
vi.mock('../api.ts', () => ({
|
|
10
|
-
listWorkspaces: vi.fn(),
|
|
11
|
-
listDirectory: vi.fn(),
|
|
12
|
-
createWorkspace: vi.fn(),
|
|
13
|
-
}))
|
|
14
|
-
import { listWorkspaces, listDirectory, createWorkspace } from '../api.ts'
|
|
15
|
-
|
|
16
|
-
const listWorkspacesMock = vi.mocked(listWorkspaces)
|
|
17
|
-
const listDirectoryMock = vi.mocked(listDirectory)
|
|
18
|
-
const createWorkspaceMock = vi.mocked(createWorkspace)
|
|
19
|
-
|
|
20
|
-
const workspaces: WorkspaceRow[] = [
|
|
21
|
-
{
|
|
22
|
-
workspaceId: 'ws-1' as never,
|
|
23
|
-
path: '/tmp/first',
|
|
24
|
-
title: 'First',
|
|
25
|
-
sessionIds: [] as never,
|
|
26
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
27
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
workspaceId: 'ws-2' as never,
|
|
31
|
-
path: '/tmp/second',
|
|
32
|
-
title: 'Second',
|
|
33
|
-
sessionIds: [] as never,
|
|
34
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
35
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
36
|
-
},
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
cleanup()
|
|
41
|
-
vi.clearAllMocks()
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe('mobile workspace deep link', () => {
|
|
45
|
-
it('reads a non-empty workspace target from the query', () => {
|
|
46
|
-
expect(mobileWorkspaceTarget('?workspace=ws-2')).toBe('ws-2')
|
|
47
|
-
expect(mobileWorkspaceTarget('?workspace=')).toBeUndefined()
|
|
48
|
-
expect(mobileWorkspaceTarget('')).toBeUndefined()
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('opens the targeted workspace as soon as the roster loads', async () => {
|
|
52
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
53
|
-
const onPick = vi.fn()
|
|
54
|
-
|
|
55
|
-
render(<WorkspaceView initialWorkspaceId="ws-2" onPick={onPick} />)
|
|
56
|
-
|
|
57
|
-
await waitFor(() => expect(onPick).toHaveBeenCalledWith(workspaces[1]))
|
|
58
|
-
expect(onPick).toHaveBeenCalledTimes(1)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('falls back to the roster when the target no longer exists', async () => {
|
|
62
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
63
|
-
const onPick = vi.fn()
|
|
64
|
-
|
|
65
|
-
render(<WorkspaceView initialWorkspaceId="missing" onPick={onPick} />)
|
|
66
|
-
|
|
67
|
-
expect(await screen.findByText('First')).toBeTruthy()
|
|
68
|
-
expect(await screen.findByText('Second')).toBeTruthy()
|
|
69
|
-
expect(onPick).not.toHaveBeenCalled()
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
describe('mobile workspace creation', () => {
|
|
74
|
-
it('shows new workspace button and navigates to directory browser', async () => {
|
|
75
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
76
|
-
listDirectoryMock.mockResolvedValue({
|
|
77
|
-
path: '/home/user',
|
|
78
|
-
home: '/home/user',
|
|
79
|
-
crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
|
|
80
|
-
entries: [
|
|
81
|
-
{ name: 'projects', path: '/home/user/projects', hidden: false },
|
|
82
|
-
{ name: '.config', path: '/home/user/.config', hidden: true }
|
|
83
|
-
],
|
|
84
|
-
truncated: false
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
const onPick = vi.fn()
|
|
88
|
-
render(<WorkspaceView onPick={onPick} />)
|
|
89
|
-
|
|
90
|
-
// Wait for workspaces to load
|
|
91
|
-
const createBtn = await screen.findByText('+ 新建工作区')
|
|
92
|
-
|
|
93
|
-
// Click create button
|
|
94
|
-
fireEvent.click(createBtn)
|
|
95
|
-
|
|
96
|
-
// Verify directory browser renders
|
|
97
|
-
expect(await screen.findByText('选择目录')).toBeTruthy()
|
|
98
|
-
|
|
99
|
-
// Verify crumbs
|
|
100
|
-
expect(await screen.findByText('user')).toBeTruthy()
|
|
101
|
-
|
|
102
|
-
// Verify entries
|
|
103
|
-
expect(await screen.findByText('projects')).toBeTruthy()
|
|
104
|
-
expect(await screen.findByText('.config')).toBeTruthy()
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('navigates into directory and allows breadcrumb navigation', async () => {
|
|
108
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
109
|
-
|
|
110
|
-
// Initial dir
|
|
111
|
-
listDirectoryMock.mockResolvedValueOnce({
|
|
112
|
-
path: '/home/user',
|
|
113
|
-
home: '/home/user',
|
|
114
|
-
crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
|
|
115
|
-
entries: [{ name: 'projects', path: '/home/user/projects', hidden: false }],
|
|
116
|
-
truncated: false
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
const onPick = vi.fn()
|
|
120
|
-
render(<WorkspaceView onPick={onPick} />)
|
|
121
|
-
|
|
122
|
-
// Open dir browser
|
|
123
|
-
fireEvent.click(await screen.findByText('+ 新建工作区'))
|
|
124
|
-
|
|
125
|
-
// Wait for first dir listing
|
|
126
|
-
const projBtn = await screen.findByText('projects')
|
|
127
|
-
|
|
128
|
-
// Prepare next listing
|
|
129
|
-
listDirectoryMock.mockResolvedValueOnce({
|
|
130
|
-
path: '/home/user/projects',
|
|
131
|
-
home: '/home/user',
|
|
132
|
-
crumbs: [
|
|
133
|
-
{ name: '', path: '/', hidden: false },
|
|
134
|
-
{ name: 'home', path: '/home', hidden: false },
|
|
135
|
-
{ name: 'user', path: '/home/user', hidden: false },
|
|
136
|
-
{ name: 'projects', path: '/home/user/projects', hidden: false }
|
|
137
|
-
],
|
|
138
|
-
entries: [{ name: 'foo', path: '/home/user/projects/foo', hidden: false }],
|
|
139
|
-
truncated: false
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// Click into folder
|
|
143
|
-
fireEvent.click(projBtn)
|
|
144
|
-
|
|
145
|
-
// Verify new contents load
|
|
146
|
-
expect(await screen.findByText('foo')).toBeTruthy()
|
|
147
|
-
|
|
148
|
-
// Check breadcrumb
|
|
149
|
-
const homeCrumb = screen.getByText('home')
|
|
150
|
-
expect(homeCrumb).toBeTruthy()
|
|
151
|
-
|
|
152
|
-
// Click breadcrumb
|
|
153
|
-
listDirectoryMock.mockResolvedValueOnce({
|
|
154
|
-
path: '/home',
|
|
155
|
-
home: '/home/user',
|
|
156
|
-
crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }],
|
|
157
|
-
entries: [{ name: 'user', path: '/home/user', hidden: false }],
|
|
158
|
-
truncated: false
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
fireEvent.click(homeCrumb)
|
|
162
|
-
expect(await screen.findByText('user')).toBeTruthy()
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('creates workspace successfully', async () => {
|
|
166
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
167
|
-
listDirectoryMock.mockResolvedValue({
|
|
168
|
-
path: '/home/user/projects/foo',
|
|
169
|
-
home: '/home/user',
|
|
170
|
-
crumbs: [{ name: '', path: '/', hidden: false }, { name: 'foo', path: '/home/user/projects/foo', hidden: false }],
|
|
171
|
-
entries: [],
|
|
172
|
-
truncated: false
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
const newWorkspace = { ...workspaces[0], workspaceId: 'ws-new' as never, title: 'foo' }
|
|
176
|
-
createWorkspaceMock.mockResolvedValue({
|
|
177
|
-
workspace: newWorkspace,
|
|
178
|
-
created: true
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
const onPick = vi.fn()
|
|
182
|
-
render(<WorkspaceView onPick={onPick} />)
|
|
183
|
-
|
|
184
|
-
fireEvent.click(await screen.findByText('+ 新建工作区'))
|
|
185
|
-
|
|
186
|
-
const selectBtn = await screen.findByText('选择此目录')
|
|
187
|
-
fireEvent.click(selectBtn)
|
|
188
|
-
|
|
189
|
-
expect(await screen.findByText('创建中…')).toBeTruthy()
|
|
190
|
-
|
|
191
|
-
await waitFor(() => {
|
|
192
|
-
expect(createWorkspaceMock).toHaveBeenCalledWith('/home/user/projects/foo')
|
|
193
|
-
expect(onPick).toHaveBeenCalledWith(newWorkspace)
|
|
194
|
-
})
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
it('handles errors from directory listing and workspace creation', async () => {
|
|
198
|
-
listWorkspacesMock.mockResolvedValue(workspaces)
|
|
199
|
-
listDirectoryMock.mockRejectedValue(new Error('Permission denied'))
|
|
200
|
-
|
|
201
|
-
const onPick = vi.fn()
|
|
202
|
-
render(<WorkspaceView onPick={onPick} />)
|
|
203
|
-
|
|
204
|
-
fireEvent.click(await screen.findByText('+ 新建工作区'))
|
|
205
|
-
|
|
206
|
-
// Verify error from listing
|
|
207
|
-
expect(await screen.findByText('Permission denied')).toBeTruthy()
|
|
208
|
-
|
|
209
|
-
// Retry with success
|
|
210
|
-
listDirectoryMock.mockResolvedValue({
|
|
211
|
-
path: '/home',
|
|
212
|
-
home: '/home/user',
|
|
213
|
-
crumbs: [],
|
|
214
|
-
entries: [],
|
|
215
|
-
truncated: false
|
|
216
|
-
})
|
|
217
|
-
fireEvent.click(screen.getByText('重试'))
|
|
218
|
-
|
|
219
|
-
const selectBtn = await screen.findByText('选择此目录')
|
|
220
|
-
|
|
221
|
-
// Fail creation
|
|
222
|
-
createWorkspaceMock.mockRejectedValue(new Error('Already exists'))
|
|
223
|
-
fireEvent.click(selectBtn)
|
|
224
|
-
|
|
225
|
-
expect(await screen.findByText('Already exists')).toBeTruthy()
|
|
226
|
-
})
|
|
227
|
-
})
|