dsh-explorer 0.0.0-stage → 0.1.0

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,160 @@
1
+ import React, { createElement, memo } from 'react'
2
+ import { ExplorerView } from './ExplorerView.tsx'
3
+ import { attachGlobalConversationDropListener } from './drag-drop.ts'
4
+ import { setupCaretTracker } from './conversation-draft.ts'
5
+
6
+ export const inject = [
7
+ 'slots',
8
+ 'sessions',
9
+ 'locale',
10
+ ]
11
+
12
+ const ExplorerTabIcon = () => (
13
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
14
+ <path d="M14 4.5V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h6.5L14 4.5zm-1 0H9.5a.5.5 0 0 1-.5-.5V2H3v12h10V4.5z"/>
15
+ </svg>
16
+ )
17
+
18
+ const ExplorerTabTitle = memo(function ExplorerTabTitle() {
19
+ return (
20
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
21
+ <ExplorerTabIcon />
22
+ <span>Explorer</span>
23
+ </span>
24
+ )
25
+ })
26
+
27
+ const ExplorerPaneBody = memo(function ExplorerPaneBody(props: { ctx?: any; sessionId?: string }) {
28
+ return <ExplorerView ctx={props.ctx} sessionId={props.sessionId} />
29
+ })
30
+
31
+ const ExplorerHeaderButton = ({ ctx, sessionId }: { ctx: any; sessionId?: string }) => {
32
+ return (
33
+ <button
34
+ type="button"
35
+ title="Explorer (File Tree)"
36
+ aria-label="Explorer"
37
+ onClick={() => {
38
+ try {
39
+ const sr = ctx.get ? ctx.get('sidebarRight') : ctx.sidebarRight
40
+ if (sessionId && sr?.openTabIn) {
41
+ sr.openTabIn(sessionId, 'explorer')
42
+ } else if (sr?.openTab) {
43
+ sr.openTab('explorer')
44
+ }
45
+ } catch (err) {
46
+ console.warn('[dsh-explorer] failed to open sidebar tab:', err)
47
+ }
48
+ }}
49
+ style={{
50
+ display: 'inline-flex',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ background: 'transparent',
54
+ border: 'none',
55
+ cursor: 'pointer',
56
+ padding: '3px 6px',
57
+ borderRadius: '6px',
58
+ color: 'var(--dsw-alias-label-secondary, #8b949e)',
59
+ transition: 'color 0.15s, background-color 0.15s',
60
+ }}
61
+ onMouseEnter={(e) => {
62
+ e.currentTarget.style.color = 'var(--dsw-alias-label-primary, #ffffff)'
63
+ e.currentTarget.style.backgroundColor = 'var(--dsw-alias-bg-hover, rgba(255, 255, 255, 0.08))'
64
+ }}
65
+ onMouseLeave={(e) => {
66
+ e.currentTarget.style.color = 'var(--dsw-alias-label-secondary, #8b949e)'
67
+ e.currentTarget.style.backgroundColor = 'transparent'
68
+ }}
69
+ >
70
+ <ExplorerTabIcon />
71
+ </button>
72
+ )
73
+ }
74
+
75
+ export function apply(ctx: any): void {
76
+ // 1. Live composer caret tracker (for precise insertion point)
77
+ ctx.effect(() => {
78
+ try {
79
+ return setupCaretTracker()
80
+ } catch (err) {
81
+ console.warn('[dsh-explorer] failed to setup caret tracker:', err)
82
+ return () => {}
83
+ }
84
+ }, 'dsh-explorer: caret tracker')
85
+
86
+ // 2. Global drag & drop listener onto DSH chat conversation
87
+ ctx.effect(() => {
88
+ try {
89
+ return attachGlobalConversationDropListener(ctx)
90
+ } catch (err) {
91
+ console.warn('[dsh-explorer] failed to attach conversation drop listener:', err)
92
+ return () => {}
93
+ }
94
+ }, 'dsh-explorer: conversation drop listener')
95
+
96
+ // 2. Direct icon button in conversation session header utilities
97
+ ctx.slots.inject('conversation.session.header.utilities', () =>
98
+ ctx.slots.register(
99
+ {
100
+ name: 'conversation.session.header.utilities',
101
+ id: 'dsh-explorer:header-button',
102
+ order: 15,
103
+ inject: (sessionId: string) => ({ sessionId }),
104
+ },
105
+ (props: { sessionId?: string }) => <ExplorerHeaderButton ctx={ctx} sessionId={props.sessionId} />
106
+ )
107
+ )
108
+
109
+ // 3. Register native tab into DSH right sidebar
110
+ ctx.inject(['sidebarRightTabs'], (injected: any) => {
111
+ const tabs = injected.get ? injected.get('sidebarRightTabs') : injected.sidebarRightTabs
112
+ if (!tabs?.register) return
113
+
114
+ // Register 'explorer' kind
115
+ const unregisterExplorer = tabs.register({
116
+ id: 'dsh-explorer',
117
+ kind: 'explorer',
118
+ keepMounted: true,
119
+ priority: 'extension',
120
+ title: () => 'Explorer',
121
+ guide: [
122
+ {
123
+ id: 'dsh-explorer',
124
+ order: 5,
125
+ title: () => 'Explorer',
126
+ description: () => 'VS Code style file explorer with drag & drop to chat',
127
+ icon: ExplorerTabIcon,
128
+ },
129
+ ],
130
+ })
131
+
132
+ // Register pane slots for 'dsh-explorer'
133
+ const offBody = ctx.slots.inject('sidebar.right.pane.tab', () =>
134
+ ctx.slots.register(
135
+ {
136
+ name: 'sidebar.right.pane.tab',
137
+ key: 'dsh-explorer',
138
+ inject: (sessionId: string) => ({ ctx, sessionId }),
139
+ },
140
+ ExplorerPaneBody
141
+ )
142
+ )
143
+ const offTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () =>
144
+ ctx.slots.register(
145
+ {
146
+ name: 'sidebar.right.pane.tab.title',
147
+ key: 'dsh-explorer',
148
+ inject: (sessionId: string) => ({ sessionId }),
149
+ },
150
+ ExplorerTabTitle
151
+ )
152
+ )
153
+
154
+ return () => {
155
+ try { offTitle?.() } catch {}
156
+ try { offBody?.() } catch {}
157
+ try { unregisterExplorer?.() } catch {}
158
+ }
159
+ })
160
+ }
@@ -0,0 +1,59 @@
1
+ export type ExplorerDensity = 'compact' | 'normal' | 'relaxed'
2
+ export type SingleClickAction = 'select' | 'mention'
3
+
4
+ export interface ExplorerOptions {
5
+ scale: number
6
+ density: ExplorerDensity
7
+ enableMotion: boolean
8
+ showHiddenFiles: boolean
9
+ showGitBadges: boolean
10
+ compactFolders: boolean
11
+ singleClickAction: SingleClickAction
12
+ }
13
+
14
+ export const DEFAULT_OPTIONS: ExplorerOptions = {
15
+ scale: 1.0,
16
+ density: 'normal',
17
+ enableMotion: true,
18
+ showHiddenFiles: true,
19
+ showGitBadges: true,
20
+ compactFolders: false,
21
+ singleClickAction: 'select',
22
+ }
23
+
24
+ const STORAGE_KEY = 'dsh_explorer_options_v1'
25
+
26
+ export function loadExplorerOptions(): ExplorerOptions {
27
+ if (typeof window === 'undefined' || !window.localStorage) {
28
+ return { ...DEFAULT_OPTIONS }
29
+ }
30
+ try {
31
+ const raw = window.localStorage.getItem(STORAGE_KEY)
32
+ if (!raw) return { ...DEFAULT_OPTIONS }
33
+ const parsed = JSON.parse(raw)
34
+ return {
35
+ scale:
36
+ typeof parsed.scale === 'number' && Number.isFinite(parsed.scale)
37
+ ? Math.max(0.75, Math.min(1.35, Math.round(parsed.scale * 100) / 100))
38
+ : DEFAULT_OPTIONS.scale,
39
+ density: parsed.density === 'compact' || parsed.density === 'relaxed' ? parsed.density : 'normal',
40
+ enableMotion: typeof parsed.enableMotion === 'boolean' ? parsed.enableMotion : DEFAULT_OPTIONS.enableMotion,
41
+ showHiddenFiles:
42
+ typeof parsed.showHiddenFiles === 'boolean' ? parsed.showHiddenFiles : DEFAULT_OPTIONS.showHiddenFiles,
43
+ showGitBadges:
44
+ typeof parsed.showGitBadges === 'boolean' ? parsed.showGitBadges : DEFAULT_OPTIONS.showGitBadges,
45
+ compactFolders:
46
+ typeof parsed.compactFolders === 'boolean' ? parsed.compactFolders : DEFAULT_OPTIONS.compactFolders,
47
+ singleClickAction: parsed.singleClickAction === 'mention' ? 'mention' : 'select',
48
+ }
49
+ } catch {
50
+ return { ...DEFAULT_OPTIONS }
51
+ }
52
+ }
53
+
54
+ export function saveExplorerOptions(options: ExplorerOptions): void {
55
+ if (typeof window === 'undefined' || !window.localStorage) return
56
+ try {
57
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(options))
58
+ } catch {}
59
+ }
@@ -0,0 +1,250 @@
1
+ import React from 'react'
2
+
3
+ export interface IconStyle {
4
+ color: string
5
+ label?: string
6
+ }
7
+
8
+ export function getFileExtension(filename: string): string {
9
+ const at = filename.lastIndexOf('.')
10
+ return at <= 0 ? '' : filename.slice(at + 1).toLowerCase()
11
+ }
12
+
13
+ export function getLanguageColor(filename: string, isDir: boolean): string {
14
+ if (isDir) return '#dcb67a'
15
+ const ext = getFileExtension(filename)
16
+ const lowerName = filename.toLowerCase()
17
+
18
+ if (lowerName === 'dockerfile' || lowerName.startsWith('dockerfile.')) return '#2496ed'
19
+ if (lowerName === '.gitignore' || lowerName === '.gitattributes') return '#f14e32'
20
+ if (lowerName === 'package.json') return '#cb3837'
21
+ if (lowerName.startsWith('.env')) return '#ecd53f'
22
+
23
+ switch (ext) {
24
+ case 'ts':
25
+ case 'mts':
26
+ case 'cts':
27
+ return '#3178c6'
28
+ case 'tsx':
29
+ return '#3178c6'
30
+ case 'js':
31
+ case 'mjs':
32
+ case 'cjs':
33
+ return '#f7df1e'
34
+ case 'jsx':
35
+ return '#00d8ff'
36
+ case 'py':
37
+ case 'pyw':
38
+ return '#3572a5'
39
+ case 'rs':
40
+ return '#dea584'
41
+ case 'go':
42
+ return '#00add8'
43
+ case 'c':
44
+ case 'h':
45
+ return '#a8b9cc'
46
+ case 'cpp':
47
+ case 'cc':
48
+ case 'cxx':
49
+ case 'hpp':
50
+ return '#00599c'
51
+ case 'cs':
52
+ return '#178600'
53
+ case 'java':
54
+ case 'jar':
55
+ return '#b07219'
56
+ case 'kt':
57
+ case 'kts':
58
+ return '#a97bff'
59
+ case 'swift':
60
+ return '#f05138'
61
+ case 'php':
62
+ return '#777bb4'
63
+ case 'rb':
64
+ return '#cc342d'
65
+ case 'html':
66
+ case 'htm':
67
+ return '#e34c26'
68
+ case 'css':
69
+ return '#42a5f5'
70
+ case 'scss':
71
+ case 'sass':
72
+ return '#cd6799'
73
+ case 'less':
74
+ return '#1d365d'
75
+ case 'json':
76
+ case 'jsonc':
77
+ case 'json5':
78
+ return '#cbcb41'
79
+ case 'yaml':
80
+ case 'yml':
81
+ return '#cb171e'
82
+ case 'toml':
83
+ return '#9c4221'
84
+ case 'md':
85
+ case 'markdown':
86
+ case 'mdx':
87
+ return '#519aba'
88
+ case 'sql':
89
+ return '#e38c00'
90
+ case 'sh':
91
+ case 'bash':
92
+ case 'zsh':
93
+ return '#89e051'
94
+ case 'ps1':
95
+ case 'psm1':
96
+ return '#012456'
97
+ case 'svg':
98
+ return '#ff9800'
99
+ case 'png':
100
+ case 'jpg':
101
+ case 'jpeg':
102
+ case 'gif':
103
+ case 'webp':
104
+ case 'ico':
105
+ return '#ab47bc'
106
+ case 'zip':
107
+ case 'tar':
108
+ case 'gz':
109
+ case '7z':
110
+ case 'rar':
111
+ return '#ec407a'
112
+ default:
113
+ return '#90a4ae'
114
+ }
115
+ }
116
+
117
+ export function renderFileIcon(filename: string, isDir: boolean, isOpen = false, size = 16): React.ReactNode {
118
+ if (isDir) {
119
+ const folderColor = isOpen ? '#e5c07b' : '#dcb67a'
120
+ if (isOpen) {
121
+ return (
122
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
123
+ <path d="M1.5 2.5C1.5 1.94772 1.94772 1.5 2.5 1.5H6.29289C6.5581 1.5 6.81249 1.60536 7 1.79289L8.20711 3H13.5C14.0523 3 14.5 3.44772 14.5 4V5H2.5L1.5 2.5Z" fill={folderColor} opacity="0.8"/>
124
+ <path d="M1.5 5.5H14.5L13.5 13.5C13.5 14.0523 13.0523 14.5 12.5 14.5H3.5C2.94772 14.5 2.5 14.0523 2.5 13.5L1.5 5.5Z" fill={folderColor}/>
125
+ <path d="M1.5 5.5L2.8 14H13.2L14.5 5.5H1.5Z" fill="#ffe082" fillOpacity="0.25"/>
126
+ </svg>
127
+ )
128
+ }
129
+ return (
130
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
131
+ <path d="M1.5 2.5C1.5 1.94772 1.94772 1.5 2.5 1.5H6.29289C6.5581 1.5 6.81249 1.60536 7 1.79289L8.20711 3H13.5C14.0523 3 14.5 3.44772 14.5 4V13.5C14.5 14.0523 14.0523 14.5 13.5 14.5H2.5C1.94772 14.5 1.5 14.0523 1.5 13.5V2.5Z" fill={folderColor}/>
132
+ </svg>
133
+ )
134
+ }
135
+
136
+ const ext = getFileExtension(filename)
137
+ const lowerName = filename.toLowerCase()
138
+ const color = getLanguageColor(filename, false)
139
+
140
+ // Specific high-frequency icons
141
+ if (ext === 'ts') {
142
+ return (
143
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
144
+ <rect width="16" height="16" rx="2.5" fill="#3178c6"/>
145
+ <text x="3" y="12" fill="#ffffff" fontSize="9.5" fontWeight="bold" fontFamily="monospace">TS</text>
146
+ </svg>
147
+ )
148
+ }
149
+ if (ext === 'tsx') {
150
+ return (
151
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
152
+ <rect width="16" height="16" rx="2.5" fill="#3178c6"/>
153
+ <circle cx="8" cy="8" r="2" fill="#00d8ff"/>
154
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none"/>
155
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none" transform="rotate(60 8 8)"/>
156
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none" transform="rotate(120 8 8)"/>
157
+ </svg>
158
+ )
159
+ }
160
+ if (ext === 'js' || ext === 'mjs' || ext === 'cjs') {
161
+ return (
162
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
163
+ <rect width="16" height="16" rx="2.5" fill="#f7df1e"/>
164
+ <text x="3.5" y="12" fill="#000000" fontSize="9.5" fontWeight="bold" fontFamily="monospace">JS</text>
165
+ </svg>
166
+ )
167
+ }
168
+ if (ext === 'jsx') {
169
+ return (
170
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
171
+ <rect width="16" height="16" rx="2.5" fill="#20232a"/>
172
+ <circle cx="8" cy="8" r="2" fill="#00d8ff"/>
173
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none"/>
174
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none" transform="rotate(60 8 8)"/>
175
+ <ellipse cx="8" cy="8" rx="5" ry="2.2" stroke="#00d8ff" strokeWidth="0.9" fill="none" transform="rotate(120 8 8)"/>
176
+ </svg>
177
+ )
178
+ }
179
+ if (ext === 'py') {
180
+ return (
181
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
182
+ <path d="M7.9 1.5C5.2 1.5 5.3 2.7 5.3 2.7V4H8V4.4H3.8C2.5 4.4 1.5 5.5 1.5 7.2C1.5 8.9 2.3 9.4 3.3 9.4H4.3V8.1C4.3 6.6 5.5 5.5 7 5.5H9.6C10.5 5.5 11.2 4.8 11.2 3.9V2.7C11.2 1.8 10.3 1.5 7.9 1.5ZM6.5 2.6C6.9 2.6 7.2 2.9 7.2 3.3C7.2 3.7 6.9 4 6.5 4C6.1 4 5.8 3.7 5.8 3.3C5.8 2.9 6.1 2.6 6.5 2.6Z" fill="#3572a5"/>
183
+ <path d="M8.1 14.5C10.8 14.5 10.7 13.3 10.7 13.3V12H8V11.6H12.2C13.5 11.6 14.5 10.5 14.5 8.8C14.5 7.1 13.7 6.6 12.7 6.6H11.7V7.9C11.7 9.4 10.5 10.5 9 10.5H6.4C5.5 10.5 4.8 11.2 4.8 12.1V13.3C4.8 14.2 5.7 14.5 8.1 14.5ZM9.5 13.4C9.1 13.4 8.8 13.1 8.8 12.7C8.8 12.3 9.1 12 9.5 12C9.9 12 10.2 12.3 10.2 12.7C10.2 13.1 9.9 13.4 9.5 13.4Z" fill="#ffd43b"/>
184
+ </svg>
185
+ )
186
+ }
187
+ if (ext === 'rs') {
188
+ return (
189
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
190
+ <circle cx="8" cy="8" r="6.5" stroke="#dea584" strokeWidth="1.2" fill="#1e1e1e"/>
191
+ <path d="M5.5 5H9C9.8 5 10.5 5.5 10.5 6.3C10.5 7 10 7.6 9.3 7.8L10.8 11H9.3L8 8.2H7V11H5.5V5ZM7 7.1H8.8C9.2 7.1 9.5 6.8 9.5 6.4C9.5 6 9.2 5.8 8.8 5.8H7V7.1Z" fill="#dea584"/>
192
+ </svg>
193
+ )
194
+ }
195
+ if (ext === 'go') {
196
+ return (
197
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
198
+ <rect width="16" height="16" rx="2.5" fill="#00add8"/>
199
+ <text x="2" y="11.5" fill="#ffffff" fontSize="8" fontWeight="bold" fontFamily="sans-serif">GO</text>
200
+ </svg>
201
+ )
202
+ }
203
+ if (ext === 'json') {
204
+ return (
205
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
206
+ <path d="M2.5 2C2.5 1.44772 2.94772 1 3.5 1H10L14 5V14C14 14.5523 13.5523 15 13 15H3.5C2.94772 15 2.5 14.5523 2.5 14V2Z" fill="#2d3748"/>
207
+ <text x="4" y="11" fill="#cbcb41" fontSize="9" fontWeight="bold" fontFamily="monospace">{'{}'}</text>
208
+ </svg>
209
+ )
210
+ }
211
+ if (ext === 'md' || ext === 'markdown') {
212
+ return (
213
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
214
+ <rect x="1" y="3" width="14" height="10" rx="1.5" stroke="#519aba" strokeWidth="1.2" fill="#1e1e1e"/>
215
+ <path d="M3.5 10V6L5.5 8.5L7.5 6V10" stroke="#519aba" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round"/>
216
+ <path d="M11 6V10M9.5 8.5L11 10L12.5 8.5" stroke="#519aba" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round"/>
217
+ </svg>
218
+ )
219
+ }
220
+ if (lowerName === '.gitignore' || lowerName === '.gitattributes') {
221
+ return (
222
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
223
+ <path d="M14.6 7.4L8.6 1.4C8.2 1 7.6 1 7.2 1.4L1.4 7.2C1 7.6 1 8.2 1.4 8.6L7.4 14.6C7.8 15 8.4 15 8.8 14.6L14.6 8.8C15 8.4 15 7.8 14.6 7.4ZM8.5 10.8V8.3C7.4 8.4 6.8 9 6.8 9.5C6.8 9.7 6.6 9.8 6.4 9.8C6.2 9.8 6 9.6 6 9.4C6 8.3 7 7.3 8.5 7.1V5.5C8.1 5.4 7.8 5 7.8 4.5C7.8 3.9 8.2 3.5 8.8 3.5C9.4 3.5 9.8 3.9 9.8 4.5C9.8 5 9.4 5.4 9.1 5.5V10.8C9.4 10.9 9.8 11.3 9.8 11.8C9.8 12.4 9.4 12.8 8.8 12.8C8.2 12.8 7.8 12.4 7.8 11.8C7.8 11.3 8.1 10.9 8.5 10.8Z" fill="#f14e32"/>
224
+ </svg>
225
+ )
226
+ }
227
+ if (lowerName === 'dockerfile' || lowerName.startsWith('dockerfile.')) {
228
+ return (
229
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
230
+ <path d="M14.5 7.5C14 7.5 13.5 7.8 13.2 8.2C12.4 7.7 11.2 7.7 10.5 8.2V5H8.5V7H9.5V8.5H5.5V6H3.5V8.5H2V7H1V8.5C0.4 8.8 0 9.4 0 10C0 12.2 2.5 14 6.5 14C11.5 14 14.8 11.2 15 8.5C15 8 14.8 7.5 14.5 7.5Z" fill="#2496ed"/>
231
+ </svg>
232
+ )
233
+ }
234
+
235
+ // Generic document sheet with badge
236
+ const shortExt = ext.slice(0, 3).toUpperCase()
237
+ return (
238
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}>
239
+ <path d="M3 2C3 1.44772 3.44772 1 4 1H10L14 5V14C14 14.5523 13.5523 15 13 15H4C3.44772 15 3 14.5523 3 14V2Z" fill="#2b313c" stroke="#3e4451" strokeWidth="0.8"/>
240
+ <path d="M10 1V5H14" fill="#3e4451" opacity="0.6"/>
241
+ {shortExt ? (
242
+ <text x="4" y="11" fill={color} fontSize="6" fontWeight="bold" fontFamily="monospace">{shortExt}</text>
243
+ ) : (
244
+ <circle cx="8" cy="9" r="1.8" fill={color}/>
245
+ )}
246
+ </svg>
247
+ )
248
+ }
249
+
250
+ export const renderVSCodeIcon = renderFileIcon
package/src/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+
3
+ export interface ExplorerConfig {
4
+ maxListingEntries?: number
5
+ defaultCompactFolders?: boolean
6
+ }
7
+
8
+ export const Config = z.object({
9
+ maxListingEntries: z.natural().default(2000),
10
+ defaultCompactFolders: z.boolean().default(true),
11
+ })
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { once } from 'node:events'
3
+ import { createWriteStream } from 'node:fs'
4
+ import { access, lstat, mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, join } from 'node:path'
6
+ import { invalidateDirectoryCache } from './fs-tree.ts'
7
+ import { assertSingleSegmentName, resolveTarget } from './path-security.ts'
8
+ import { ExplorerError } from './wire.ts'
9
+
10
+ export async function createNewFile(dirPath: string, name: string): Promise<{ path: string }> {
11
+ assertSingleSegmentName(name)
12
+ const fullPath = join(dirPath, name)
13
+ try {
14
+ await access(fullPath)
15
+ throw new ExplorerError('already-exists', `file "${name}" already exists`, 409)
16
+ } catch (err) {
17
+ if (err instanceof ExplorerError) throw err
18
+ }
19
+ await writeFile(fullPath, '', { flag: 'wx' })
20
+ invalidateDirectoryCache(dirPath)
21
+ return { path: fullPath }
22
+ }
23
+
24
+ export async function createDirectory(dirPath: string, name: string): Promise<{ path: string }> {
25
+ assertSingleSegmentName(name)
26
+ const fullPath = join(dirPath, name)
27
+ try {
28
+ await access(fullPath)
29
+ throw new ExplorerError('already-exists', `directory "${name}" already exists`, 409)
30
+ } catch (err) {
31
+ if (err instanceof ExplorerError) throw err
32
+ }
33
+ await mkdir(fullPath, { recursive: true })
34
+ invalidateDirectoryCache(dirPath)
35
+ return { path: fullPath }
36
+ }
37
+
38
+ export async function renameEntry(targetPath: string, newName: string): Promise<{ oldPath: string; newPath: string }> {
39
+ assertSingleSegmentName(newName)
40
+ const parent = dirname(targetPath)
41
+ const newPath = join(parent, newName)
42
+ if (targetPath === newPath) return { oldPath: targetPath, newPath }
43
+
44
+ try {
45
+ await access(newPath)
46
+ throw new ExplorerError('already-exists', `destination "${newName}" already exists`, 409)
47
+ } catch (err) {
48
+ if (err instanceof ExplorerError) throw err
49
+ }
50
+
51
+ await rename(targetPath, newPath)
52
+ invalidateDirectoryCache(parent)
53
+ return { oldPath: targetPath, newPath }
54
+ }
55
+
56
+ export async function removeEntry(targetPath: string): Promise<void> {
57
+ const parent = dirname(targetPath)
58
+ const st = await lstat(targetPath)
59
+ if (st.isDirectory()) {
60
+ await rm(targetPath, { recursive: true, force: true })
61
+ } else {
62
+ await unlink(targetPath)
63
+ }
64
+ invalidateDirectoryCache(parent)
65
+ }
66
+
67
+ export async function readFileText(filePath: string, maxBytes = 1024 * 1024 * 2): Promise<{ text: string; size: number }> {
68
+ const content = await readFile(filePath)
69
+ if (content.length > maxBytes) {
70
+ throw new ExplorerError('payload-too-large', `file exceeds max preview limit of ${maxBytes} bytes`, 413)
71
+ }
72
+ return {
73
+ text: content.toString('utf-8'),
74
+ size: content.length,
75
+ }
76
+ }
77
+
78
+ export async function writeUpload(
79
+ baseDir: string,
80
+ relativePath: string,
81
+ streamChunks: AsyncIterable<Buffer | Uint8Array>,
82
+ ): Promise<{ path: string; size: number }> {
83
+ if (!relativePath || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
84
+ throw new ExplorerError('bad-request', 'relativePath must stay within upload target', 400)
85
+ }
86
+ const segments = relativePath.split(/[\\/]/)
87
+ if (segments.some(p => !p || p === '.' || p === '..')) {
88
+ throw new ExplorerError('bad-request', 'relativePath contains invalid path traversal segments', 400)
89
+ }
90
+ const target = join(baseDir, ...segments)
91
+ const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`)
92
+ await mkdir(dirname(target), { recursive: true })
93
+
94
+ const writeStream = createWriteStream(tmp, { flags: 'wx' })
95
+ let bytes = 0
96
+
97
+ try {
98
+ for await (const chunk of streamChunks) {
99
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
100
+ bytes += buf.length
101
+ if (!writeStream.write(buf)) {
102
+ await once(writeStream, 'drain')
103
+ }
104
+ }
105
+ writeStream.end()
106
+ await once(writeStream, 'finish')
107
+ await rename(tmp, target)
108
+ invalidateDirectoryCache(dirname(target))
109
+ return { path: target, size: bytes }
110
+ } catch (err) {
111
+ writeStream.destroy()
112
+ try { await unlink(tmp) } catch {}
113
+ throw err
114
+ }
115
+ }
116
+