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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +8629 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +179 -0
- package/package.json +71 -0
- package/scripts/patch-layout.mjs +323 -0
- package/src/client/FileExplorer.tsx +374 -0
- package/src/client/FilePreview.tsx +353 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/files.module.css +536 -0
- package/src/client/highlight.ts +88 -0
- package/src/client/index.ts +45 -0
- package/src/client/locales.ts +39 -0
- package/src/client/store.ts +151 -0
- package/src/dsh.d.ts +62 -0
- package/src/index.ts +203 -0
|
@@ -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
|
+
}
|