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.
- package/LICENSE +22 -0
- package/README.md +106 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +2814 -0
- package/lib/index.js +747 -0
- package/package.json +92 -4
- package/src/client/ExplorerContextMenu.tsx +159 -0
- package/src/client/ExplorerHeader.tsx +75 -0
- package/src/client/ExplorerOptionsModal.tsx +243 -0
- package/src/client/ExplorerRow.tsx +240 -0
- package/src/client/ExplorerSearch.tsx +42 -0
- package/src/client/ExplorerView.tsx +1139 -0
- package/src/client/api.ts +121 -0
- package/src/client/conversation-draft.ts +635 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag-drop.ts +209 -0
- package/src/client/explorer.module.css +628 -0
- package/src/client/index.tsx +160 -0
- package/src/client/options.ts +59 -0
- package/src/client/vscode-theme.tsx +250 -0
- package/src/config.ts +11 -0
- package/src/fs-operations.ts +116 -0
- package/src/fs-tree.ts +155 -0
- package/src/fs-watch.ts +179 -0
- package/src/git-status.ts +161 -0
- package/src/index.ts +165 -0
- package/src/path-security.ts +22 -0
- package/src/session-path.ts +35 -0
- package/src/wire.ts +63 -0
|
@@ -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
|
+
|