dsh-plugin-workbench 0.0.1

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Module-local store shared by the explorer tree (FileExplorer) and the split
3
+ * preview (FilePreview). Tabs, active file, and collapsed state are kept
4
+ * PER WORKSPACE (keyed by the session cwd): switching workspaces swaps to that
5
+ * workspace's own set, and coming back restores it. Both components live in
6
+ * the same client bundle, so this store is a single instance across the two
7
+ * slot registrations.
8
+ */
9
+ import { useSyncExternalStore } from 'react'
10
+
11
+ export type FeTheme = 'light' | 'dark'
12
+
13
+ interface PerWorkspace {
14
+ tabs: string[]
15
+ active: string | undefined
16
+ collapsed: boolean
17
+ }
18
+
19
+ export interface TabsStateView {
20
+ tabs: string[]
21
+ active: string | undefined
22
+ collapsed: boolean
23
+ theme: FeTheme
24
+ cwd: string | undefined
25
+ }
26
+
27
+ const EMPTY_TABS: string[] = []
28
+ const EMPTY_WORKSPACE: PerWorkspace = { tabs: EMPTY_TABS, active: undefined, collapsed: false }
29
+
30
+ interface StoreState {
31
+ currentCwd: string | undefined
32
+ workspaces: Record<string, PerWorkspace>
33
+ theme: FeTheme
34
+ }
35
+
36
+ let state: StoreState = { currentCwd: undefined, workspaces: {}, theme: 'dark' }
37
+ let view: TabsStateView = { tabs: EMPTY_TABS, active: undefined, collapsed: false, theme: 'dark', cwd: undefined }
38
+ const listeners = new Set<() => void>()
39
+
40
+ function workspaceOf(cwd: string | undefined): PerWorkspace {
41
+ return cwd !== undefined ? state.workspaces[cwd] ?? EMPTY_WORKSPACE : EMPTY_WORKSPACE
42
+ }
43
+
44
+ export function getTabsState(): TabsStateView {
45
+ return view
46
+ }
47
+
48
+ export function subscribeTabs(listener: () => void): () => void {
49
+ listeners.add(listener)
50
+ return () => {
51
+ listeners.delete(listener)
52
+ }
53
+ }
54
+
55
+ export function useTabsState(): TabsStateView {
56
+ return useSyncExternalStore(subscribeTabs, getTabsState)
57
+ }
58
+
59
+ function commit(next: StoreState): void {
60
+ state = next
61
+ const ws = workspaceOf(state.currentCwd)
62
+ const nextView: TabsStateView = {
63
+ tabs: ws.tabs,
64
+ active: ws.active,
65
+ collapsed: ws.collapsed,
66
+ theme: state.theme,
67
+ cwd: state.currentCwd,
68
+ }
69
+ if (
70
+ nextView.tabs === view.tabs
71
+ && nextView.active === view.active
72
+ && nextView.collapsed === view.collapsed
73
+ && nextView.theme === view.theme
74
+ && nextView.cwd === view.cwd
75
+ ) return
76
+ view = nextView
77
+ for (const listener of [...listeners]) listener()
78
+ }
79
+
80
+ /** Update the current workspace's record; no-op without a current workspace. */
81
+ function updateCurrent(updater: (ws: PerWorkspace) => PerWorkspace): void {
82
+ const cwd = state.currentCwd
83
+ if (cwd === undefined) return
84
+ const prev = state.workspaces[cwd] ?? EMPTY_WORKSPACE
85
+ const next = updater(prev)
86
+ if (next === prev) return
87
+ commit({ ...state, workspaces: { ...state.workspaces, [cwd]: next } })
88
+ }
89
+
90
+ /** Point the store at a workspace (called when the session's cwd changes). */
91
+ export function setCwd(cwd: string | undefined): void {
92
+ if (state.currentCwd === cwd) return
93
+ commit({ ...state, currentCwd: cwd })
94
+ }
95
+
96
+ /** Open a file in the current workspace's tabs (or activate it); pops the pane out. */
97
+ export function openFile(path: string): void {
98
+ updateCurrent((ws) => {
99
+ if (ws.tabs.includes(path)) {
100
+ if (ws.active === path && !ws.collapsed) return ws
101
+ return { ...ws, active: path, collapsed: false }
102
+ }
103
+ return { ...ws, tabs: [...ws.tabs, path], active: path, collapsed: false }
104
+ })
105
+ }
106
+
107
+ /** Close one tab; if it was active, activate its neighbor. */
108
+ export function closeFile(path: string): void {
109
+ updateCurrent((ws) => {
110
+ const index = ws.tabs.indexOf(path)
111
+ if (index < 0) return ws
112
+ const tabs = ws.tabs.filter((t) => t !== path)
113
+ let active = ws.active
114
+ if (active === path) active = tabs[Math.min(index, tabs.length - 1)]
115
+ return { ...ws, tabs, active }
116
+ })
117
+ }
118
+
119
+ /** Switch the active tab without re-reading. */
120
+ export function activateFile(path: string): void {
121
+ updateCurrent((ws) => (ws.active === path || !ws.tabs.includes(path) ? ws : { ...ws, active: path }))
122
+ }
123
+
124
+ /** Move a tab before another tab (drag-to-reorder). */
125
+ export function moveTab(dragged: string, target: string): void {
126
+ updateCurrent((ws) => {
127
+ if (dragged === target) return ws
128
+ const from = ws.tabs.indexOf(dragged)
129
+ const to = ws.tabs.indexOf(target)
130
+ if (from < 0 || to < 0) return ws
131
+ const tabs = [...ws.tabs]
132
+ tabs.splice(from, 1)
133
+ tabs.splice(to, 0, dragged)
134
+ return { ...ws, tabs }
135
+ })
136
+ }
137
+
138
+ /** Tuck the preview pane away, keeping all open tabs and their content. */
139
+ export function collapsePreview(): void {
140
+ updateCurrent((ws) => (ws.collapsed ? ws : { ...ws, collapsed: true }))
141
+ }
142
+
143
+ /** Pop the preview pane back out, restoring the tabs as they were. */
144
+ export function expandPreview(): void {
145
+ updateCurrent((ws) => (ws.collapsed ? { ...ws, collapsed: false } : ws))
146
+ }
147
+
148
+ /** Toggle the file-browser light/dark theme (applies to every workspace). */
149
+ export function toggleTheme(): void {
150
+ commit({ ...state, theme: state.theme === 'dark' ? 'light' : 'dark' })
151
+ }
package/src/dsh.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Local minimal type declarations for the members of the dsh runtime this
3
+ * plugin touches. tsdown does NOT type-check, so these are DX-only aids: the
4
+ * running service is the authority. Augmenting `@deepseek-ai/cordis`'s Context
5
+ * keeps the host/client sources free of the runtime's whole `.ts`-suffixed
6
+ * d.ts chain.
7
+ */
8
+
9
+ declare module '@deepseek-ai/cordis' {
10
+ interface RpcOkShape {
11
+ ok: true
12
+ value: unknown
13
+ }
14
+ interface RpcErrShape {
15
+ ok: false
16
+ error: {
17
+ code: string
18
+ message: string
19
+ details: Record<string, unknown>
20
+ }
21
+ }
22
+ type RpcShape = RpcOkShape | RpcErrShape
23
+ type RpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<RpcShape>
24
+
25
+ interface Context {
26
+ /** Register a lifecycle effect; the callback may return a disposer. */
27
+ effect(callback: () => void | (() => void), name?: string): void
28
+ connection: {
29
+ rpc: {
30
+ /** Browser-side unary call over a registered logical channel. */
31
+ call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<RpcShape>
32
+ /** Host-side channel registration; returns an async disposer. */
33
+ handle(channel: string, handler: RpcHandler, options: { authority: 'loopback' | 'trusted-host' }): () => Promise<void>
34
+ }
35
+ }
36
+ slots: {
37
+ register(options: {
38
+ name?: string
39
+ locale?: string
40
+ children?: Record<string, unknown>
41
+ store?: unknown
42
+ inject?: (...args: unknown[]) => Record<string, unknown>
43
+ [key: string]: unknown
44
+ }, component: unknown): () => void
45
+ inject(key: string, callback: () => (() => void) | Iterable<() => void>): () => void
46
+ }
47
+ locale: {
48
+ register(ns: string, dicts: Record<string, Record<string, string>>): () => void
49
+ }
50
+ workspaces: {
51
+ openPath(path: string): Promise<void>
52
+ }
53
+ fs: {
54
+ resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<{ targetKey: unknown; displayPath: string }>
55
+ stat(target: unknown, signal?: AbortSignal): Promise<{ version: unknown; type: 'file' | 'directory' | 'other'; size?: number } | undefined>
56
+ listDir(target: unknown, signal?: AbortSignal): Promise<Array<{ name: string; type: 'file' | 'directory' | 'other'; target: unknown; version?: unknown; size?: number }>>
57
+ readText(target: unknown, signal?: AbortSignal): Promise<string>
58
+ writeText(target: unknown, content: string, version?: unknown, signal?: AbortSignal, options?: { mode?: string; workspaceRoot?: string }): Promise<void>
59
+ processPath(target: unknown): string
60
+ }
61
+ }
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Host half of the workbench plugin.
3
+ *
4
+ * Registers one loopback-only generic RPC channel (`/dsh-plugin-files`) with
5
+ * two endpoints, both implemented over `ctx.fs` (the sandboxed filesystem
6
+ * service). Reads pass through untouched in every sandbox mode, so this
7
+ * plugin only ever lists and reads — it never mutates the workspace.
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+
11
+ export const name = 'dsh-plugin-workbench'
12
+ export const inject = ['fs', 'connection']
13
+
14
+ /** Loopback-only logical RPC channel. */
15
+ export const CHANNEL = '/dsh-plugin-files'
16
+
17
+ /** Files larger than this are never read for preview (client shows size + hint). */
18
+ export const MAX_PREVIEW_BYTES = 512 * 1024
19
+
20
+ export type FsKind = 'dir' | 'file' | 'other'
21
+
22
+ export interface FsListEntry {
23
+ name: string
24
+ path: string
25
+ kind: FsKind
26
+ size?: number
27
+ }
28
+
29
+ export interface FsListResult {
30
+ root: string
31
+ entries: FsListEntry[]
32
+ }
33
+
34
+ export interface FsReadResult {
35
+ path: string
36
+ content: string
37
+ size: number
38
+ binary: boolean
39
+ truncated: boolean
40
+ }
41
+
42
+ export interface FsWriteResult {
43
+ path: string
44
+ size: number
45
+ }
46
+
47
+ export type FilesRpcOk = { ok: true; value: FsListResult | FsReadResult | FsWriteResult }
48
+ export type FilesRpcErr = { ok: false; error: { code: 'internal'; message: string; details: Record<string, never> } }
49
+ export type FilesRpcResult = FilesRpcOk | FilesRpcErr
50
+
51
+ /**
52
+ * Raw `ctx.fs.listDir` row shape (loose — the real service is authoritative at
53
+ * runtime; these types only document the contract and keep the pure helpers
54
+ * testable without importing the whole runtime d.ts chain).
55
+ */
56
+ export interface RawDirEntry {
57
+ name: string
58
+ type: 'file' | 'directory' | 'other'
59
+ target: unknown
60
+ size?: number
61
+ }
62
+
63
+ /** Map an fs entry type onto the wire `kind` union. */
64
+ export function kindOf(type: string): FsKind {
65
+ if (type === 'directory') return 'dir'
66
+ if (type === 'file') return 'file'
67
+ return 'other'
68
+ }
69
+
70
+ /** Project a raw fs dir entry onto the wire-safe row shape. */
71
+ export function mapDirEntry(entry: RawDirEntry, processPath: (target: unknown) => string): FsListEntry {
72
+ const kind = kindOf(entry.type)
73
+ return {
74
+ name: entry.name,
75
+ path: processPath(entry.target),
76
+ kind,
77
+ ...(kind === 'file' && entry.size !== undefined ? { size: entry.size } : {}),
78
+ }
79
+ }
80
+
81
+ /** Directories first, then case-insensitive name sort (numeric-aware). */
82
+ export function sortEntries(entries: FsListEntry[]): FsListEntry[] {
83
+ return [...entries].sort((a, b) => {
84
+ const ad = a.kind === 'dir' ? 0 : 1
85
+ const bd = b.kind === 'dir' ? 0 : 1
86
+ if (ad !== bd) return ad - bd
87
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
88
+ })
89
+ }
90
+
91
+ const FS_ERROR_MESSAGES: Record<string, string> = {
92
+ FS_NOT_FOUND: 'path does not exist',
93
+ FS_NOT_DIRECTORY: 'not a directory',
94
+ FS_NOT_REGULAR_FILE: 'not a regular file',
95
+ FS_NOT_TEXT: 'binary file',
96
+ FS_TOO_LARGE: 'file too large',
97
+ FS_PERMISSION_DENIED: 'permission denied',
98
+ FS_SANDBOX_DENIED: 'sandbox denied',
99
+ FS_ABORTED: 'aborted',
100
+ FS_IO_ERROR: 'io error',
101
+ }
102
+
103
+ /** Human-readable message for a thrown value, honoring the fs error code taxonomy. */
104
+ export function mapError(error: unknown): string {
105
+ if (error instanceof Error) {
106
+ const code = (error as { code?: unknown }).code
107
+ if (typeof code === 'string' && FS_ERROR_MESSAGES[code] !== undefined) return FS_ERROR_MESSAGES[code]
108
+ return error.message
109
+ }
110
+ return String(error)
111
+ }
112
+
113
+ function isFsErrorCode(error: unknown, code: string): boolean {
114
+ return error instanceof Error && (error as { code?: unknown }).code === code
115
+ }
116
+
117
+ function fail(message: string): FilesRpcErr {
118
+ return { ok: false, error: { code: 'internal', message, details: {} } }
119
+ }
120
+
121
+ function pathOf(payload: unknown): string | undefined {
122
+ if (typeof payload === 'object' && payload !== null) {
123
+ const path = (payload as { path?: unknown }).path
124
+ if (typeof path === 'string' && path.trim().length > 0) return path
125
+ }
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
131
+ * cancels the underlying fs call (or aborts between steps).
132
+ */
133
+ export function apply(ctx: Context): void {
134
+ const handler = async (endpoint: string, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> => {
135
+ if (endpoint === 'list') return listDir(ctx, payload, signal)
136
+ if (endpoint === 'read') return readFile(ctx, payload, signal)
137
+ if (endpoint === 'write') return writeFile(ctx, payload, signal)
138
+ return fail(`unknown endpoint: ${endpoint}`)
139
+ }
140
+ ctx.connection.rpc.handle(CHANNEL, handler, { authority: 'loopback' })
141
+ }
142
+
143
+ async function listDir(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
144
+ const path = pathOf(payload)
145
+ if (path === undefined) return fail('list: payload.path must be a non-empty string')
146
+ try {
147
+ const target = await ctx.fs.resolve(path, { signal })
148
+ const info = await ctx.fs.stat(target, signal)
149
+ if (info === undefined) return fail(`path not found: ${path}`)
150
+ if (info.type !== 'directory') return fail(`not a directory: ${path}`)
151
+ const raw = await ctx.fs.listDir(target, signal)
152
+ const entries = sortEntries(raw.map((entry) => mapDirEntry(entry, (t) => ctx.fs.processPath(t))))
153
+ return { ok: true, value: { root: ctx.fs.processPath(target), entries } }
154
+ } catch (error) {
155
+ return fail(mapError(error))
156
+ }
157
+ }
158
+
159
+ async function readFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
160
+ const path = pathOf(payload)
161
+ if (path === undefined) return fail('read: payload.path must be a non-empty string')
162
+ try {
163
+ const target = await ctx.fs.resolve(path, { signal })
164
+ const info = await ctx.fs.stat(target, signal)
165
+ if (info === undefined) return fail(`path not found: ${path}`)
166
+ if (info.type !== 'file') return fail(`not a regular file: ${path}`)
167
+ const resolvedPath = ctx.fs.processPath(target)
168
+ const size = info.size ?? 0
169
+ if (size > MAX_PREVIEW_BYTES) {
170
+ return { ok: true, value: { path: resolvedPath, content: '', size, binary: false, truncated: true } }
171
+ }
172
+ try {
173
+ const content = await ctx.fs.readText(target, signal)
174
+ return { ok: true, value: { path: resolvedPath, content, size, binary: false, truncated: false } }
175
+ } catch (error) {
176
+ if (isFsErrorCode(error, 'FS_NOT_TEXT')) {
177
+ return { ok: true, value: { path: resolvedPath, content: '', size, binary: true, truncated: false } }
178
+ }
179
+ throw error
180
+ }
181
+ } catch (error) {
182
+ return fail(mapError(error))
183
+ }
184
+ }
185
+
186
+ async function writeFile(ctx: Context, payload: unknown, signal: AbortSignal): Promise<FilesRpcResult> {
187
+ const path = pathOf(payload)
188
+ const content = typeof payload === 'object' && payload !== null ? (payload as { content?: unknown }).content : undefined
189
+ if (path === undefined) return fail('write: payload.path must be a non-empty string')
190
+ if (typeof content !== 'string') return fail('write: payload.content must be a string')
191
+ try {
192
+ const target = await ctx.fs.resolve(path, { signal })
193
+ // Editing is an explicit user action over the loopback-only channel; run
194
+ // it unfenced (mirrors the /api write tools under danger-full-access).
195
+ await ctx.fs.writeText(target, content, undefined, signal, {
196
+ mode: 'danger-full-access',
197
+ workspaceRoot: ctx.fs.processPath(target),
198
+ })
199
+ return { ok: true, value: { path: ctx.fs.processPath(target), size: content.length } }
200
+ } catch (error) {
201
+ return fail(mapError(error))
202
+ }
203
+ }