@yiln-dsh/dsh-plugin-file-explorer 0.5.0 → 0.6.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/README.md +6 -4
- package/client.js +273 -98
- package/index.js +37 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
|
|
|
36
36
|
|
|
37
37
|
## Install
|
|
38
38
|
|
|
39
|
-
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.
|
|
39
|
+
The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.6.0`.
|
|
40
40
|
|
|
41
41
|
The right-panel package must be installed in the same `web` profile:
|
|
42
42
|
|
|
@@ -53,7 +53,7 @@ pnpm pack
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.
|
|
56
|
+
dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.6.0.tgz
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
### npm package
|
|
@@ -76,8 +76,10 @@ apply the new profile composition.
|
|
|
76
76
|
- The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
|
|
77
77
|
and returns `{ ok, path, parent, entries }` JSON — never live objects.
|
|
78
78
|
- `read` previews up to 1 MiB of UTF-8 text or 16 MiB of recognized images;
|
|
79
|
-
image previews return a data URL for inline rendering. `download`
|
|
80
|
-
|
|
79
|
+
image previews return a data URL for inline rendering. `download` is a GET
|
|
80
|
+
route (`/_dsh/file-explorer/download?path=...`) that streams the file
|
|
81
|
+
natively (`Content-Disposition: attachment`) — no base64, no JSON body, no
|
|
82
|
+
size ceiling. The browser triggers it with a transient `<a download>` link.
|
|
81
83
|
- `delete` removes regular files (`rm -f`) and directories recursively
|
|
82
84
|
(`rm -rf`); both are called only after a second-click confirm in the UI.
|
|
83
85
|
- Git routes are read-only. They resolve the repository root with
|
package/client.js
CHANGED
|
@@ -21,11 +21,180 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
|
|
22
22
|
const inject = ["slots", "rightPanel"];
|
|
23
23
|
|
|
24
|
+
const LOCALE_NS = "file-explorer";
|
|
25
|
+
const ZH_DICT = {
|
|
26
|
+
"files.title": "文件",
|
|
27
|
+
"git.title": "Git 图谱",
|
|
28
|
+
"workspace": "工作区",
|
|
29
|
+
"files.refresh": "刷新",
|
|
30
|
+
"files.refreshList": "刷新文件列表",
|
|
31
|
+
"git.refreshGraph": "刷新图谱",
|
|
32
|
+
"files.up": "上一级",
|
|
33
|
+
"files.goUp": "返回上级目录",
|
|
34
|
+
"files.delete": "删除",
|
|
35
|
+
"files.deleteFile": "删除 {name}",
|
|
36
|
+
"files.confirmDelete": "确认删除 {name}",
|
|
37
|
+
"files.clickAgain": "再次点击确认删除",
|
|
38
|
+
"files.deleteFailed": "删除失败",
|
|
39
|
+
"files.view": "查看",
|
|
40
|
+
"files.viewFile": "查看 {name}",
|
|
41
|
+
"files.download": "下载",
|
|
42
|
+
"files.downloadFile": "下载 {name}",
|
|
43
|
+
"files.downloadFailed": "下载失败",
|
|
44
|
+
"files.closePreview": "关闭预览",
|
|
45
|
+
"files.preview": "预览",
|
|
46
|
+
"files.fullscreen": "全屏",
|
|
47
|
+
"files.enterFullscreen": "进入全屏",
|
|
48
|
+
"files.exitFullscreen": "退出全屏",
|
|
49
|
+
"files.imageControls": "图片控制",
|
|
50
|
+
"files.zoomIn": "放大",
|
|
51
|
+
"files.zoomOut": "缩小",
|
|
52
|
+
"files.resetZoom": "重置缩放",
|
|
53
|
+
"files.resetImageZoom": "重置图片缩放",
|
|
54
|
+
"files.imagePreview": "图片预览",
|
|
55
|
+
"files.previewDialog": "文件预览",
|
|
56
|
+
"files.workspaceRoot": "工作区根目录",
|
|
57
|
+
"files.loading": "加载中…",
|
|
58
|
+
"git.loading": "加载中…",
|
|
59
|
+
"files.empty": "空文件夹",
|
|
60
|
+
"files.listFailed": "文件列表加载失败",
|
|
61
|
+
"files.readFailed": "文件读取失败",
|
|
62
|
+
"files.imageMeta": "图片 / {size}",
|
|
63
|
+
"files.binary": "二进制文件",
|
|
64
|
+
"files.fileTooLarge": "文件过大,无法在此预览",
|
|
65
|
+
"files.imageTooLarge": "图片过大,无法在此预览",
|
|
66
|
+
"files.noPreview": "(无预览)",
|
|
67
|
+
"files.binaryNoPreview": "二进制文件无法预览",
|
|
68
|
+
"git.logFailed": "Git 提交记录加载失败",
|
|
69
|
+
"git.commitFailed": "提交详情加载失败",
|
|
70
|
+
"git.diffFailed": "差异加载失败",
|
|
71
|
+
"git.loadingChanges": "加载变更…",
|
|
72
|
+
"git.noDiff": "(无差异)",
|
|
73
|
+
"git.noMessage": "(无提交信息)",
|
|
74
|
+
"git.diffTruncated": "…(差异已截断)",
|
|
75
|
+
"git.uncommitted": "未提交的更改",
|
|
76
|
+
"git.workingTree": "工作区",
|
|
77
|
+
"git.workingTreeLabel": "工作区",
|
|
78
|
+
"git.noCommitsYet": "暂无提交",
|
|
79
|
+
"git.noCommitsYetSuffix": "(暂无提交)",
|
|
80
|
+
"git.noData": "暂无数据",
|
|
81
|
+
"git.detachedHead": "游离 HEAD",
|
|
82
|
+
"git.allBranches": "所有分支",
|
|
83
|
+
"git.branchFilter": "分支筛选",
|
|
84
|
+
"git.currentBranch": "当前分支",
|
|
85
|
+
"git.diff": "差异",
|
|
86
|
+
"git.closeDiff": "关闭差异",
|
|
87
|
+
"git.authorUnknown": "未知",
|
|
88
|
+
"git.changedFiles.one": "共 {count} 个变更文件",
|
|
89
|
+
"git.changedFiles.other": "共 {count} 个变更文件",
|
|
90
|
+
"git.changedVsHead.one": "共 {count} 个变更文件(对比 HEAD)",
|
|
91
|
+
"git.changedVsHead.other": "共 {count} 个变更文件(对比 HEAD)",
|
|
92
|
+
"git.changesCount.one": "{count} 个变更",
|
|
93
|
+
"git.changesCount.other": "{count} 个变更",
|
|
94
|
+
"time.justNow": "刚刚",
|
|
95
|
+
"time.minutesAgo": "{n} 分钟前",
|
|
96
|
+
"time.hoursAgo": "{n} 小时前",
|
|
97
|
+
"time.daysAgo": "{n} 天前",
|
|
98
|
+
"time.weeksAgo": "{n} 周前",
|
|
99
|
+
"time.monthsAgo": "{n} 个月前",
|
|
100
|
+
"time.yearsAgo": "{n} 年前",
|
|
101
|
+
};
|
|
102
|
+
const EN_DICT = {
|
|
103
|
+
"files.title": "Files",
|
|
104
|
+
"git.title": "Git Graph",
|
|
105
|
+
"workspace": "Workspace",
|
|
106
|
+
"files.refresh": "Refresh",
|
|
107
|
+
"files.refreshList": "Refresh file list",
|
|
108
|
+
"git.refreshGraph": "Refresh graph",
|
|
109
|
+
"files.up": "Up",
|
|
110
|
+
"files.goUp": "Go to parent directory",
|
|
111
|
+
"files.delete": "Delete",
|
|
112
|
+
"files.deleteFile": "Delete {name}",
|
|
113
|
+
"files.confirmDelete": "Confirm deleting {name}",
|
|
114
|
+
"files.clickAgain": "Click again to delete",
|
|
115
|
+
"files.deleteFailed": "Delete failed",
|
|
116
|
+
"files.view": "View",
|
|
117
|
+
"files.viewFile": "View {name}",
|
|
118
|
+
"files.download": "Download",
|
|
119
|
+
"files.downloadFile": "Download {name}",
|
|
120
|
+
"files.downloadFailed": "Download failed",
|
|
121
|
+
"files.closePreview": "Close preview",
|
|
122
|
+
"files.preview": "Preview",
|
|
123
|
+
"files.fullscreen": "Fullscreen",
|
|
124
|
+
"files.enterFullscreen": "Enter fullscreen",
|
|
125
|
+
"files.exitFullscreen": "Exit fullscreen",
|
|
126
|
+
"files.imageControls": "Image controls",
|
|
127
|
+
"files.zoomIn": "Zoom in",
|
|
128
|
+
"files.zoomOut": "Zoom out",
|
|
129
|
+
"files.resetZoom": "Reset zoom",
|
|
130
|
+
"files.resetImageZoom": "Reset image zoom",
|
|
131
|
+
"files.imagePreview": "Image preview",
|
|
132
|
+
"files.previewDialog": "File preview",
|
|
133
|
+
"files.workspaceRoot": "workspace root",
|
|
134
|
+
"files.loading": "Loading...",
|
|
135
|
+
"git.loading": "Loading…",
|
|
136
|
+
"files.empty": "Empty folder",
|
|
137
|
+
"files.listFailed": "Failed to list files",
|
|
138
|
+
"files.readFailed": "Failed to read file",
|
|
139
|
+
"files.imageMeta": "Image / {size}",
|
|
140
|
+
"files.binary": "Binary",
|
|
141
|
+
"files.fileTooLarge": "File is too large to preview here",
|
|
142
|
+
"files.imageTooLarge": "Image is too large to preview here",
|
|
143
|
+
"files.noPreview": "(No preview)",
|
|
144
|
+
"files.binaryNoPreview": "Binary file cannot be previewed",
|
|
145
|
+
"git.logFailed": "Failed to load git log",
|
|
146
|
+
"git.commitFailed": "Failed to load commit",
|
|
147
|
+
"git.diffFailed": "Failed to load diff",
|
|
148
|
+
"git.loadingChanges": "Loading changes…",
|
|
149
|
+
"git.noDiff": "(no diff)",
|
|
150
|
+
"git.noMessage": "(no message)",
|
|
151
|
+
"git.diffTruncated": "… (diff truncated)",
|
|
152
|
+
"git.uncommitted": "Uncommitted changes",
|
|
153
|
+
"git.workingTree": "Working tree",
|
|
154
|
+
"git.workingTreeLabel": "working tree",
|
|
155
|
+
"git.noCommitsYet": "No commits yet",
|
|
156
|
+
"git.noCommitsYetSuffix": " (no commits yet)",
|
|
157
|
+
"git.noData": "No data",
|
|
158
|
+
"git.detachedHead": "detached HEAD",
|
|
159
|
+
"git.allBranches": "All branches",
|
|
160
|
+
"git.branchFilter": "Branch filter",
|
|
161
|
+
"git.currentBranch": "Current branch",
|
|
162
|
+
"git.diff": "Diff",
|
|
163
|
+
"git.closeDiff": "Close diff",
|
|
164
|
+
"git.authorUnknown": "unknown",
|
|
165
|
+
"git.changedFiles.one": "{count} changed file",
|
|
166
|
+
"git.changedFiles.other": "{count} changed files",
|
|
167
|
+
"git.changedVsHead.one": "{count} changed file (vs HEAD)",
|
|
168
|
+
"git.changedVsHead.other": "{count} changed files (vs HEAD)",
|
|
169
|
+
"git.changesCount.one": "{count} change",
|
|
170
|
+
"git.changesCount.other": "{count} changes",
|
|
171
|
+
"time.justNow": "just now",
|
|
172
|
+
"time.minutesAgo": "{n}m ago",
|
|
173
|
+
"time.hoursAgo": "{n}h ago",
|
|
174
|
+
"time.daysAgo": "{n}d ago",
|
|
175
|
+
"time.weeksAgo": "{n}w ago",
|
|
176
|
+
"time.monthsAgo": "{n}mo ago",
|
|
177
|
+
"time.yearsAgo": "{n}y ago",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
function applyParams(template, params) {
|
|
181
|
+
if (!params) return template;
|
|
182
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
183
|
+
}
|
|
184
|
+
|
|
24
185
|
function apply(ctx) {
|
|
25
186
|
const slots = ctx.get('slots')
|
|
26
187
|
const rightPanel = ctx.get('rightPanel')
|
|
27
188
|
if (slots === undefined || rightPanel === undefined) return
|
|
28
189
|
|
|
190
|
+
const locale = ctx.get("locale");
|
|
191
|
+
if (locale !== undefined) {
|
|
192
|
+
ctx.effect(() => locale.register(LOCALE_NS, { zh: ZH_DICT, en: EN_DICT }), "file-explorer: locale");
|
|
193
|
+
}
|
|
194
|
+
const t = locale !== undefined
|
|
195
|
+
? locale.bind(LOCALE_NS)
|
|
196
|
+
: (key, params) => applyParams(ZH_DICT[key] ?? EN_DICT[key] ?? key, params);
|
|
197
|
+
|
|
29
198
|
const style = document.createElement("style");
|
|
30
199
|
style.id = "dsh-plugin-file-explorer-style";
|
|
31
200
|
style.setAttribute("data-plugin", "dsh-plugin-file-explorer");
|
|
@@ -703,16 +872,16 @@ window.__ModuleLoader__.load({
|
|
|
703
872
|
return STATUS_COLORS[letter] || '#6b7280'
|
|
704
873
|
}
|
|
705
874
|
|
|
706
|
-
function relTime(epochSeconds) {
|
|
875
|
+
function relTime(epochSeconds, t) {
|
|
707
876
|
if (typeof epochSeconds !== 'number' || !Number.isFinite(epochSeconds) || epochSeconds <= 0) return ''
|
|
708
877
|
const s = Math.max(0, Date.now() / 1000 - epochSeconds)
|
|
709
|
-
if (s < 60) return '
|
|
710
|
-
if (s < 3600) return
|
|
711
|
-
if (s < 86400) return
|
|
712
|
-
if (s < 604800) return
|
|
713
|
-
if (s < 2592000) return
|
|
714
|
-
if (s < 31536000) return
|
|
715
|
-
return
|
|
878
|
+
if (s < 60) return t('time.justNow')
|
|
879
|
+
if (s < 3600) return t('time.minutesAgo', { n: Math.floor(s / 60) })
|
|
880
|
+
if (s < 86400) return t('time.hoursAgo', { n: Math.floor(s / 3600) })
|
|
881
|
+
if (s < 604800) return t('time.daysAgo', { n: Math.floor(s / 86400) })
|
|
882
|
+
if (s < 2592000) return t('time.weeksAgo', { n: Math.floor(s / 604800) })
|
|
883
|
+
if (s < 31536000) return t('time.monthsAgo', { n: Math.floor(s / 2592000) })
|
|
884
|
+
return t('time.yearsAgo', { n: Math.floor(s / 31536000) })
|
|
716
885
|
}
|
|
717
886
|
|
|
718
887
|
function shortHash(hash) {
|
|
@@ -927,7 +1096,8 @@ window.__ModuleLoader__.load({
|
|
|
927
1096
|
// line, the commit lane rows with an expandable per-commit detail, and a
|
|
928
1097
|
// diff modal. Data comes entirely from the /_dsh/file-explorer/git-*
|
|
929
1098
|
// JSON API.
|
|
930
|
-
function
|
|
1099
|
+
function createGitGraphView(t) {
|
|
1100
|
+
return function GitGraphView(props) {
|
|
931
1101
|
const api = props.api
|
|
932
1102
|
const cwd = props.cwd
|
|
933
1103
|
const [scope, setScope] = React.useState('all')
|
|
@@ -955,7 +1125,7 @@ window.__ModuleLoader__.load({
|
|
|
955
1125
|
if (raw && raw.ok === true) {
|
|
956
1126
|
setLog(raw)
|
|
957
1127
|
} else {
|
|
958
|
-
setError(raw && typeof raw.error === 'string' ? raw.error : '
|
|
1128
|
+
setError(raw && typeof raw.error === 'string' ? raw.error : t('git.logFailed'))
|
|
959
1129
|
}
|
|
960
1130
|
})
|
|
961
1131
|
.catch((err) => {
|
|
@@ -981,7 +1151,7 @@ window.__ModuleLoader__.load({
|
|
|
981
1151
|
if (raw && raw.ok === true) {
|
|
982
1152
|
setDetails((prev) => ({ ...prev, [selected]: raw }))
|
|
983
1153
|
} else {
|
|
984
|
-
setDetails((prev) => ({ ...prev, [selected]: { error: raw && typeof raw.error === 'string' ? raw.error : '
|
|
1154
|
+
setDetails((prev) => ({ ...prev, [selected]: { error: raw && typeof raw.error === 'string' ? raw.error : t('git.commitFailed') } }))
|
|
985
1155
|
}
|
|
986
1156
|
})
|
|
987
1157
|
.catch((err) => {
|
|
@@ -1000,10 +1170,10 @@ window.__ModuleLoader__.load({
|
|
|
1000
1170
|
api('git-diff', { path: cwd, hash, file: file.path })
|
|
1001
1171
|
.then((raw) => {
|
|
1002
1172
|
if (raw && raw.ok === true) {
|
|
1003
|
-
const note = raw.truncated ? '\n\n
|
|
1173
|
+
const note = raw.truncated ? '\n\n' + t('git.diffTruncated') : ''
|
|
1004
1174
|
setDiff({ title: file.path, hash, loading: false, text: (raw.patch || '') + note, error: null })
|
|
1005
1175
|
} else {
|
|
1006
|
-
setDiff({ title: file.path, hash, loading: false, text: null, error: raw && typeof raw.error === 'string' ? raw.error : '
|
|
1176
|
+
setDiff({ title: file.path, hash, loading: false, text: null, error: raw && typeof raw.error === 'string' ? raw.error : t('git.diffFailed') })
|
|
1007
1177
|
}
|
|
1008
1178
|
})
|
|
1009
1179
|
.catch((err) => {
|
|
@@ -1017,7 +1187,7 @@ window.__ModuleLoader__.load({
|
|
|
1017
1187
|
const renderCommitDetail = (hash, d) => {
|
|
1018
1188
|
if (d === undefined) {
|
|
1019
1189
|
return React.createElement('div', { className: 'dsh-git-detail' },
|
|
1020
|
-
React.createElement('div', { className: 'dsh-git-files-title' }, '
|
|
1190
|
+
React.createElement('div', { className: 'dsh-git-files-title' }, t('git.loadingChanges')))
|
|
1021
1191
|
}
|
|
1022
1192
|
if (d.error) {
|
|
1023
1193
|
return React.createElement('div', { className: 'dsh-git-detail' },
|
|
@@ -1025,9 +1195,9 @@ window.__ModuleLoader__.load({
|
|
|
1025
1195
|
}
|
|
1026
1196
|
const files = Array.isArray(d.files) ? d.files : []
|
|
1027
1197
|
return React.createElement('div', { className: 'dsh-git-detail' },
|
|
1028
|
-
React.createElement('div', { className: 'dsh-git-msg' }, d.message ||
|
|
1198
|
+
React.createElement('div', { className: 'dsh-git-msg' }, d.message || t('git.noMessage')),
|
|
1029
1199
|
React.createElement('div', { className: 'dsh-git-files-title' },
|
|
1030
|
-
|
|
1200
|
+
files.length === 1 ? t('git.changedFiles.one', { count: files.length }) : t('git.changedFiles.other', { count: files.length })),
|
|
1031
1201
|
files.length === 0 ? null : files.map((f, i) =>
|
|
1032
1202
|
React.createElement(GitFileRow, { key: i, file: f, onOpen: (file) => openDiff(hash, file) })),
|
|
1033
1203
|
)
|
|
@@ -1035,7 +1205,7 @@ window.__ModuleLoader__.load({
|
|
|
1035
1205
|
|
|
1036
1206
|
const rows = []
|
|
1037
1207
|
if (loading) {
|
|
1038
|
-
rows.push(React.createElement('div', { key: 'loading', className: 'dsh-fe-status' }, '
|
|
1208
|
+
rows.push(React.createElement('div', { key: 'loading', className: 'dsh-fe-status' }, t('git.loading')))
|
|
1039
1209
|
} else if (error) {
|
|
1040
1210
|
rows.push(React.createElement('div', { key: 'error', className: 'dsh-fe-status dsh-fe-status-error' }, error))
|
|
1041
1211
|
} else if (graph !== null) {
|
|
@@ -1049,21 +1219,21 @@ window.__ModuleLoader__.load({
|
|
|
1049
1219
|
className: 'dsh-git-row',
|
|
1050
1220
|
onClick: () => toggle('WORKING'),
|
|
1051
1221
|
role: 'button',
|
|
1052
|
-
title: '
|
|
1222
|
+
title: t('git.uncommitted'),
|
|
1053
1223
|
},
|
|
1054
1224
|
React.createElement(WorkTreeCell, { lane: first !== null ? first.lane : 0, connectorColor }),
|
|
1055
1225
|
React.createElement('div', { className: 'dsh-git-main' },
|
|
1056
1226
|
React.createElement('div', { className: 'dsh-git-line1' },
|
|
1057
|
-
React.createElement('span', { className: 'dsh-git-subject' }, '
|
|
1227
|
+
React.createElement('span', { className: 'dsh-git-subject' }, t('git.uncommitted')),
|
|
1058
1228
|
React.createElement('span', { className: 'dsh-git-pill dsh-git-pill-branch' }, String(changes.length)),
|
|
1059
1229
|
),
|
|
1060
|
-
React.createElement('div', { className: 'dsh-git-line2' }, '
|
|
1230
|
+
React.createElement('div', { className: 'dsh-git-line2' }, t('git.workingTree')),
|
|
1061
1231
|
),
|
|
1062
1232
|
),
|
|
1063
1233
|
selected === 'WORKING'
|
|
1064
1234
|
? React.createElement('div', { className: 'dsh-git-detail' },
|
|
1065
1235
|
React.createElement('div', { className: 'dsh-git-files-title' },
|
|
1066
|
-
|
|
1236
|
+
changes.length === 1 ? t('git.changedVsHead.one', { count: changes.length }) : t('git.changedVsHead.other', { count: changes.length })),
|
|
1067
1237
|
changes.map((f, i) =>
|
|
1068
1238
|
React.createElement(GitFileRow, { key: i, file: f, onOpen: (file) => openDiff('WORKING', file) })),
|
|
1069
1239
|
)
|
|
@@ -1086,23 +1256,23 @@ window.__ModuleLoader__.load({
|
|
|
1086
1256
|
React.createElement('span', { className: 'dsh-git-subject' }, c.subject),
|
|
1087
1257
|
),
|
|
1088
1258
|
React.createElement('div', { className: 'dsh-git-line2' },
|
|
1089
|
-
`${c.author || '
|
|
1259
|
+
`${c.author || t('git.authorUnknown')} · ${relTime(c.date, t)} · ${shortHash(c.hash)}`),
|
|
1090
1260
|
),
|
|
1091
1261
|
),
|
|
1092
1262
|
selected === c.hash ? renderCommitDetail(c.hash, details[c.hash]) : null,
|
|
1093
1263
|
))
|
|
1094
1264
|
})
|
|
1095
1265
|
if (rows.length === 0) {
|
|
1096
|
-
rows.push(React.createElement('div', { key: 'empty', className: 'dsh-fe-status' }, '
|
|
1266
|
+
rows.push(React.createElement('div', { key: 'empty', className: 'dsh-fe-status' }, t('git.noCommitsYet')))
|
|
1097
1267
|
}
|
|
1098
1268
|
} else {
|
|
1099
|
-
rows.push(React.createElement('div', { key: 'none', className: 'dsh-fe-status' }, '
|
|
1269
|
+
rows.push(React.createElement('div', { key: 'none', className: 'dsh-fe-status' }, t('git.noData')))
|
|
1100
1270
|
}
|
|
1101
1271
|
|
|
1102
1272
|
const branchLabel = status
|
|
1103
1273
|
? (status.branch === null
|
|
1104
|
-
? '
|
|
1105
|
-
: status.branch + (status.unborn ? '
|
|
1274
|
+
? t('git.detachedHead')
|
|
1275
|
+
: status.branch + (status.unborn ? t('git.noCommitsYetSuffix') : ''))
|
|
1106
1276
|
: null
|
|
1107
1277
|
const upstreamBits = []
|
|
1108
1278
|
if (status && status.ahead > 0) upstreamBits.push('↑' + status.ahead)
|
|
@@ -1114,17 +1284,17 @@ window.__ModuleLoader__.load({
|
|
|
1114
1284
|
type: 'button',
|
|
1115
1285
|
className: 'dsh-fe-toolbutton',
|
|
1116
1286
|
onClick: reload,
|
|
1117
|
-
'aria-label': '
|
|
1118
|
-
title: '
|
|
1287
|
+
'aria-label': t('git.refreshGraph'),
|
|
1288
|
+
title: t('files.refresh'),
|
|
1119
1289
|
}, svgIcon(refreshIcon, 15)),
|
|
1120
1290
|
React.createElement('select', {
|
|
1121
1291
|
className: 'dsh-git-scope',
|
|
1122
1292
|
value: scope,
|
|
1123
|
-
'aria-label': '
|
|
1293
|
+
'aria-label': t('git.branchFilter'),
|
|
1124
1294
|
onChange: (event) => setScope(event.target.value),
|
|
1125
1295
|
},
|
|
1126
|
-
React.createElement('option', { value: 'all' }, '
|
|
1127
|
-
React.createElement('option', { value: 'current' }, '
|
|
1296
|
+
React.createElement('option', { value: 'all' }, t('git.allBranches')),
|
|
1297
|
+
React.createElement('option', { value: 'current' }, t('git.currentBranch')),
|
|
1128
1298
|
),
|
|
1129
1299
|
),
|
|
1130
1300
|
branchLabel !== null
|
|
@@ -1132,7 +1302,7 @@ window.__ModuleLoader__.load({
|
|
|
1132
1302
|
svgIcon(branchIcon, 12),
|
|
1133
1303
|
React.createElement('span', { className: 'dsh-git-info-name' }, branchLabel),
|
|
1134
1304
|
upstreamBits.length > 0 ? React.createElement('span', null, upstreamBits.join(' ')) : null,
|
|
1135
|
-
React.createElement('span', null,
|
|
1305
|
+
React.createElement('span', null, changes.length === 1 ? t('git.changesCount.one', { count: changes.length }) : t('git.changesCount.other', { count: changes.length })),
|
|
1136
1306
|
)
|
|
1137
1307
|
: null,
|
|
1138
1308
|
React.createElement('div', { className: 'dsh-git-list' }, rows),
|
|
@@ -1144,29 +1314,31 @@ window.__ModuleLoader__.load({
|
|
|
1144
1314
|
React.createElement('div', {
|
|
1145
1315
|
className: 'dsh-fe-modal',
|
|
1146
1316
|
role: 'dialog',
|
|
1147
|
-
'aria-label': '
|
|
1317
|
+
'aria-label': t('git.diff'),
|
|
1148
1318
|
onClick: (event) => event.stopPropagation(),
|
|
1149
1319
|
},
|
|
1150
1320
|
React.createElement('div', { className: 'dsh-fe-modal-head' },
|
|
1151
1321
|
React.createElement('div', { className: 'dsh-fe-modal-title' }, diff.title),
|
|
1152
1322
|
React.createElement('div', { className: 'dsh-fe-modal-meta' },
|
|
1153
|
-
diff.hash === 'WORKING' ? '
|
|
1323
|
+
diff.hash === 'WORKING' ? t('git.workingTreeLabel') : shortHash(diff.hash)),
|
|
1154
1324
|
React.createElement('button', {
|
|
1155
1325
|
type: 'button',
|
|
1156
1326
|
className: 'dsh-fe-icon',
|
|
1157
1327
|
onClick: () => setDiff(null),
|
|
1158
|
-
'aria-label': '
|
|
1328
|
+
'aria-label': t('git.closeDiff'),
|
|
1159
1329
|
}, svgIcon(closeIcon, 14)),
|
|
1160
1330
|
),
|
|
1161
1331
|
React.createElement('div', { className: 'dsh-fe-modal-body' },
|
|
1162
|
-
diff.loading ? '
|
|
1332
|
+
diff.loading ? t('git.loading') : (diff.error || diff.text || t('git.noDiff'))),
|
|
1163
1333
|
),
|
|
1164
1334
|
)
|
|
1165
1335
|
: null,
|
|
1166
1336
|
)
|
|
1167
1337
|
}
|
|
1338
|
+
}
|
|
1168
1339
|
|
|
1169
|
-
function
|
|
1340
|
+
function createFileExplorerPage(t) {
|
|
1341
|
+
return function FileExplorerPage(props) {
|
|
1170
1342
|
if (typeof props.useSessions !== 'function') return null
|
|
1171
1343
|
|
|
1172
1344
|
const sessionId = props.useSessions((state) => {
|
|
@@ -1231,7 +1403,7 @@ window.__ModuleLoader__.load({
|
|
|
1231
1403
|
setCurrentPath(requestPath)
|
|
1232
1404
|
setParentPath(null)
|
|
1233
1405
|
setEntries([])
|
|
1234
|
-
setError(raw && typeof raw.error === 'string' ? raw.error : '
|
|
1406
|
+
setError(raw && typeof raw.error === 'string' ? raw.error : t('files.listFailed'))
|
|
1235
1407
|
}
|
|
1236
1408
|
})
|
|
1237
1409
|
.catch((err) => {
|
|
@@ -1333,7 +1505,7 @@ window.__ModuleLoader__.load({
|
|
|
1333
1505
|
setPreview(null)
|
|
1334
1506
|
api('read', { path: entry.path })
|
|
1335
1507
|
.then((raw) => {
|
|
1336
|
-
setPreview(raw && raw.ok === true ? raw : { ok: false, error: raw && typeof raw.error === 'string' ? raw.error : '
|
|
1508
|
+
setPreview(raw && raw.ok === true ? raw : { ok: false, error: raw && typeof raw.error === 'string' ? raw.error : t('files.readFailed') })
|
|
1337
1509
|
})
|
|
1338
1510
|
.catch((err) => {
|
|
1339
1511
|
setPreview({ ok: false, error: err && typeof err.message === 'string' ? err.message : String(err) })
|
|
@@ -1343,25 +1515,21 @@ window.__ModuleLoader__.load({
|
|
|
1343
1515
|
|
|
1344
1516
|
const downloadFile = (entry) => {
|
|
1345
1517
|
setPendingDelete(null)
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
setError(err && typeof err.message === 'string' ? err.message : String(err))
|
|
1362
|
-
}
|
|
1363
|
-
})
|
|
1364
|
-
.catch((err) => setError(err && typeof err.message === 'string' ? err.message : String(err)))
|
|
1518
|
+
// Native streaming download: navigate to the Host route, which
|
|
1519
|
+
// streams the file with `Content-Disposition: attachment`. The
|
|
1520
|
+
// browser handles the download natively — no JSON data URL, no
|
|
1521
|
+
// base64, no full-file buffering in page memory.
|
|
1522
|
+
try {
|
|
1523
|
+
const query = new URLSearchParams({ path: entry.path })
|
|
1524
|
+
const link = document.createElement('a')
|
|
1525
|
+
link.href = `/_dsh/file-explorer/download?${query.toString()}`
|
|
1526
|
+
link.download = entry.name
|
|
1527
|
+
document.body.appendChild(link)
|
|
1528
|
+
link.click()
|
|
1529
|
+
link.remove()
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
setError(err && typeof err.message === 'string' ? err.message : String(err))
|
|
1532
|
+
}
|
|
1365
1533
|
}
|
|
1366
1534
|
|
|
1367
1535
|
const requestDelete = (entry) => {
|
|
@@ -1373,7 +1541,7 @@ window.__ModuleLoader__.load({
|
|
|
1373
1541
|
api('delete', { path: entry.path })
|
|
1374
1542
|
.then((raw) => {
|
|
1375
1543
|
if (!raw || raw.ok !== true) {
|
|
1376
|
-
setError(raw && typeof raw.error === 'string' ? raw.error : '
|
|
1544
|
+
setError(raw && typeof raw.error === 'string' ? raw.error : t('files.deleteFailed'))
|
|
1377
1545
|
return
|
|
1378
1546
|
}
|
|
1379
1547
|
reload()
|
|
@@ -1397,9 +1565,9 @@ window.__ModuleLoader__.load({
|
|
|
1397
1565
|
: React.createElement('button', {
|
|
1398
1566
|
type: 'button',
|
|
1399
1567
|
className: 'dsh-fe-path',
|
|
1400
|
-
title: currentPath || '
|
|
1568
|
+
title: currentPath || t('files.workspaceRoot'),
|
|
1401
1569
|
onClick: startEditPath,
|
|
1402
|
-
}, currentPath || '
|
|
1570
|
+
}, currentPath || t('files.workspaceRoot'))
|
|
1403
1571
|
|
|
1404
1572
|
const filesView = React.createElement(React.Fragment, null,
|
|
1405
1573
|
pathControl,
|
|
@@ -1411,24 +1579,24 @@ window.__ModuleLoader__.load({
|
|
|
1411
1579
|
onClick: () => {
|
|
1412
1580
|
if (parentPath !== null && parentPath !== currentPath) setRequestPath(parentPath)
|
|
1413
1581
|
},
|
|
1414
|
-
'aria-label': '
|
|
1415
|
-
title: '
|
|
1582
|
+
'aria-label': t('files.goUp'),
|
|
1583
|
+
title: t('files.up'),
|
|
1416
1584
|
}, svgIcon(upIcon, 15)),
|
|
1417
1585
|
React.createElement('button', {
|
|
1418
1586
|
type: 'button',
|
|
1419
1587
|
className: 'dsh-fe-toolbutton',
|
|
1420
1588
|
onClick: reload,
|
|
1421
|
-
'aria-label': '
|
|
1422
|
-
title: '
|
|
1589
|
+
'aria-label': t('files.refreshList'),
|
|
1590
|
+
title: t('files.refresh'),
|
|
1423
1591
|
}, svgIcon(refreshIcon, 15)),
|
|
1424
1592
|
),
|
|
1425
1593
|
React.createElement('div', { className: 'dsh-fe-list' },
|
|
1426
1594
|
loading
|
|
1427
|
-
? React.createElement('div', { className: 'dsh-fe-status' }, '
|
|
1595
|
+
? React.createElement('div', { className: 'dsh-fe-status' }, t('files.loading'))
|
|
1428
1596
|
: error
|
|
1429
1597
|
? React.createElement('div', { className: 'dsh-fe-status dsh-fe-status-error' }, error)
|
|
1430
1598
|
: sorted.length === 0
|
|
1431
|
-
? React.createElement('div', { className: 'dsh-fe-status' }, '
|
|
1599
|
+
? React.createElement('div', { className: 'dsh-fe-status' }, t('files.empty'))
|
|
1432
1600
|
: sorted.map((entry) => {
|
|
1433
1601
|
const isDir = entry.type === 'directory'
|
|
1434
1602
|
return React.createElement('div', {
|
|
@@ -1449,22 +1617,22 @@ window.__ModuleLoader__.load({
|
|
|
1449
1617
|
type: 'button',
|
|
1450
1618
|
className: 'dsh-fe-action',
|
|
1451
1619
|
onClick: (event) => { event.stopPropagation(); openPreview(entry) },
|
|
1452
|
-
'aria-label':
|
|
1453
|
-
title: '
|
|
1620
|
+
'aria-label': t('files.viewFile', { name: entry.name }),
|
|
1621
|
+
title: t('files.view'),
|
|
1454
1622
|
}, svgIcon(eyeIcon, 14)),
|
|
1455
1623
|
!isDir && React.createElement('button', {
|
|
1456
1624
|
type: 'button',
|
|
1457
1625
|
className: 'dsh-fe-action',
|
|
1458
1626
|
onClick: (event) => { event.stopPropagation(); downloadFile(entry) },
|
|
1459
|
-
'aria-label':
|
|
1460
|
-
title: '
|
|
1627
|
+
'aria-label': t('files.downloadFile', { name: entry.name }),
|
|
1628
|
+
title: t('files.download'),
|
|
1461
1629
|
}, svgIcon(downloadIcon, 14)),
|
|
1462
1630
|
React.createElement('button', {
|
|
1463
1631
|
type: 'button',
|
|
1464
1632
|
className: pendingDelete === entry.path ? 'dsh-fe-action dsh-fe-action-confirm' : 'dsh-fe-action dsh-fe-action-danger',
|
|
1465
1633
|
onClick: (event) => { event.stopPropagation(); requestDelete(entry) },
|
|
1466
|
-
'aria-label': pendingDelete === entry.path ?
|
|
1467
|
-
title: pendingDelete === entry.path ? '
|
|
1634
|
+
'aria-label': pendingDelete === entry.path ? t('files.confirmDelete', { name: entry.name }) : t('files.deleteFile', { name: entry.name }),
|
|
1635
|
+
title: pendingDelete === entry.path ? t('files.clickAgain') : t('files.delete'),
|
|
1468
1636
|
}, svgIcon(trashIcon, 14)),
|
|
1469
1637
|
),
|
|
1470
1638
|
),
|
|
@@ -1477,7 +1645,7 @@ window.__ModuleLoader__.load({
|
|
|
1477
1645
|
React.createElement('div', {
|
|
1478
1646
|
className: 'dsh-fe-page',
|
|
1479
1647
|
role: 'region',
|
|
1480
|
-
'aria-label': '
|
|
1648
|
+
'aria-label': t('files.title'),
|
|
1481
1649
|
},
|
|
1482
1650
|
filesView,
|
|
1483
1651
|
),
|
|
@@ -1489,50 +1657,50 @@ window.__ModuleLoader__.load({
|
|
|
1489
1657
|
React.createElement('div', {
|
|
1490
1658
|
className: preview.kind === 'image' && imageFullscreen ? 'dsh-fe-modal dsh-fe-modal-fullscreen' : 'dsh-fe-modal',
|
|
1491
1659
|
role: 'dialog',
|
|
1492
|
-
'aria-label': '
|
|
1660
|
+
'aria-label': t('files.previewDialog'),
|
|
1493
1661
|
'aria-modal': true,
|
|
1494
1662
|
tabIndex: -1,
|
|
1495
1663
|
onKeyDown: handlePreviewKeyDown,
|
|
1496
1664
|
onClick: (event) => event.stopPropagation(),
|
|
1497
1665
|
},
|
|
1498
1666
|
React.createElement('div', { className: 'dsh-fe-modal-head' },
|
|
1499
|
-
React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || '
|
|
1500
|
-
React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ?
|
|
1667
|
+
React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || t('files.preview')),
|
|
1668
|
+
React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? t('files.imageMeta', { size: formatSize(preview.size) }) : preview.binary ? t('files.binary') : formatSize(preview.size)),
|
|
1501
1669
|
preview.kind === 'image' && preview.dataUrl
|
|
1502
1670
|
? React.createElement('div', {
|
|
1503
1671
|
className: 'dsh-fe-image-tools',
|
|
1504
1672
|
role: 'toolbar',
|
|
1505
|
-
'aria-label': '
|
|
1673
|
+
'aria-label': t('files.imageControls'),
|
|
1506
1674
|
},
|
|
1507
1675
|
React.createElement('button', {
|
|
1508
1676
|
type: 'button',
|
|
1509
1677
|
className: 'dsh-fe-icon dsh-fe-image-tool',
|
|
1510
1678
|
disabled: imageScale <= 0.25,
|
|
1511
1679
|
onClick: () => changeImageZoom(-0.25),
|
|
1512
|
-
'aria-label': '
|
|
1513
|
-
title: '
|
|
1680
|
+
'aria-label': t('files.zoomOut'),
|
|
1681
|
+
title: t('files.zoomOut'),
|
|
1514
1682
|
}, svgIcon(zoomOutIcon, 14)),
|
|
1515
1683
|
React.createElement('button', {
|
|
1516
1684
|
type: 'button',
|
|
1517
1685
|
className: 'dsh-fe-icon dsh-fe-image-tool',
|
|
1518
1686
|
onClick: resetImageZoom,
|
|
1519
|
-
'aria-label': '
|
|
1520
|
-
title: '
|
|
1687
|
+
'aria-label': t('files.resetImageZoom'),
|
|
1688
|
+
title: t('files.resetZoom'),
|
|
1521
1689
|
}, React.createElement('span', { className: 'dsh-fe-image-zoom-label' }, `${Math.round(imageScale * 100)}%`)),
|
|
1522
1690
|
React.createElement('button', {
|
|
1523
1691
|
type: 'button',
|
|
1524
1692
|
className: 'dsh-fe-icon dsh-fe-image-tool',
|
|
1525
1693
|
disabled: imageScale >= 4,
|
|
1526
1694
|
onClick: () => changeImageZoom(0.25),
|
|
1527
|
-
'aria-label': '
|
|
1528
|
-
title: '
|
|
1695
|
+
'aria-label': t('files.zoomIn'),
|
|
1696
|
+
title: t('files.zoomIn'),
|
|
1529
1697
|
}, svgIcon(zoomInIcon, 14)),
|
|
1530
1698
|
React.createElement('button', {
|
|
1531
1699
|
type: 'button',
|
|
1532
1700
|
className: 'dsh-fe-icon dsh-fe-image-tool',
|
|
1533
1701
|
onClick: () => setImageFullscreen((value) => !value),
|
|
1534
|
-
'aria-label': imageFullscreen ? '
|
|
1535
|
-
title: imageFullscreen ? '
|
|
1702
|
+
'aria-label': imageFullscreen ? t('files.exitFullscreen') : t('files.enterFullscreen'),
|
|
1703
|
+
title: imageFullscreen ? t('files.exitFullscreen') : t('files.fullscreen'),
|
|
1536
1704
|
}, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
|
|
1537
1705
|
)
|
|
1538
1706
|
: null,
|
|
@@ -1540,7 +1708,7 @@ window.__ModuleLoader__.load({
|
|
|
1540
1708
|
type: 'button',
|
|
1541
1709
|
className: 'dsh-fe-icon',
|
|
1542
1710
|
onClick: closePreview,
|
|
1543
|
-
'aria-label': '
|
|
1711
|
+
'aria-label': t('files.closePreview'),
|
|
1544
1712
|
}, svgIcon(closeIcon, 14)),
|
|
1545
1713
|
),
|
|
1546
1714
|
React.createElement('div', {
|
|
@@ -1552,7 +1720,7 @@ window.__ModuleLoader__.load({
|
|
|
1552
1720
|
onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
|
|
1553
1721
|
},
|
|
1554
1722
|
previewLoading
|
|
1555
|
-
? '
|
|
1723
|
+
? t('files.loading')
|
|
1556
1724
|
: preview.error
|
|
1557
1725
|
? preview.error
|
|
1558
1726
|
: preview.kind === 'image'
|
|
@@ -1562,23 +1730,25 @@ window.__ModuleLoader__.load({
|
|
|
1562
1730
|
className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
|
|
1563
1731
|
style: { transform: `translate3d(${imageOffset.x}px, ${imageOffset.y}px, 0) scale(${imageScale})` },
|
|
1564
1732
|
src: preview.dataUrl,
|
|
1565
|
-
alt: preview.name || '
|
|
1733
|
+
alt: preview.name || t('files.imagePreview'),
|
|
1566
1734
|
draggable: false,
|
|
1567
1735
|
})
|
|
1568
1736
|
)
|
|
1569
|
-
: preview.tooLarge ? '
|
|
1737
|
+
: preview.tooLarge ? t('files.imageTooLarge') : t('files.noPreview')
|
|
1570
1738
|
: preview.text !== undefined && preview.text !== null
|
|
1571
1739
|
? preview.text
|
|
1572
1740
|
: preview.tooLarge
|
|
1573
|
-
? '
|
|
1574
|
-
: preview.binary ? '
|
|
1741
|
+
? t('files.fileTooLarge')
|
|
1742
|
+
: preview.binary ? t('files.binaryNoPreview') : t('files.noPreview'),
|
|
1575
1743
|
),
|
|
1576
1744
|
),
|
|
1577
1745
|
),
|
|
1578
1746
|
)
|
|
1579
1747
|
}
|
|
1748
|
+
}
|
|
1580
1749
|
|
|
1581
|
-
function
|
|
1750
|
+
function createFileExplorerGitPage(t) {
|
|
1751
|
+
return function FileExplorerGitPage(props) {
|
|
1582
1752
|
if (typeof props.useSessions !== 'function') return null
|
|
1583
1753
|
|
|
1584
1754
|
const sessionId = props.useSessions((state) => {
|
|
@@ -1599,23 +1769,28 @@ window.__ModuleLoader__.load({
|
|
|
1599
1769
|
return React.createElement('div', {
|
|
1600
1770
|
className: 'dsh-fe-page',
|
|
1601
1771
|
role: 'region',
|
|
1602
|
-
'aria-label': '
|
|
1772
|
+
'aria-label': t('git.title'),
|
|
1603
1773
|
}, React.createElement(GitGraphView, { api, cwd }))
|
|
1604
1774
|
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const GitGraphView = createGitGraphView(t);
|
|
1778
|
+
const FileExplorerPage = createFileExplorerPage(t);
|
|
1779
|
+
const FileExplorerGitPage = createFileExplorerGitPage(t);
|
|
1605
1780
|
|
|
1606
1781
|
ctx.effect(() => {
|
|
1607
1782
|
const disposeFilesPage = rightPanel.registerPage({
|
|
1608
1783
|
id: 'file-explorer.files',
|
|
1609
|
-
title: '
|
|
1610
|
-
group: '
|
|
1784
|
+
title: t('files.title'),
|
|
1785
|
+
group: t('workspace'),
|
|
1611
1786
|
order: 20,
|
|
1612
1787
|
placement: 'rail',
|
|
1613
1788
|
icon: (size) => svgIcon(folderIcon, size),
|
|
1614
1789
|
})
|
|
1615
1790
|
const disposeGitPage = rightPanel.registerPage({
|
|
1616
1791
|
id: 'file-explorer.git',
|
|
1617
|
-
title: '
|
|
1618
|
-
group: '
|
|
1792
|
+
title: t('git.title'),
|
|
1793
|
+
group: t('workspace'),
|
|
1619
1794
|
order: 21,
|
|
1620
1795
|
placement: 'rail',
|
|
1621
1796
|
icon: (size) => svgIcon(branchIcon, size),
|
package/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* dsh-plugin-file-explorer — Host half (static DSH bundle).
|
|
3
5
|
*
|
|
4
6
|
* Registers exact HTTP routes under /_dsh/file-explorer for the browser
|
|
5
|
-
* bundle: list (with parent path), read (text/image preview), download
|
|
6
|
-
* and delete.
|
|
7
|
+
* bundle: list (with parent path), read (text/image preview), download
|
|
8
|
+
* (native streaming), and delete.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
function parentOf(path) {
|
|
@@ -190,29 +192,52 @@ export function apply(ctx) {
|
|
|
190
192
|
})
|
|
191
193
|
|
|
192
194
|
addRoute('/_dsh/file-explorer/download', async (req, res) => {
|
|
193
|
-
|
|
195
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
196
|
+
const requestedPath = url.searchParams.get('path')
|
|
194
197
|
const fs = ctx.get('fs')
|
|
195
198
|
if (fs === undefined) {
|
|
196
199
|
sendJson(res, 200, { ok: false, error: 'filesystem service unavailable' })
|
|
197
200
|
return
|
|
198
201
|
}
|
|
199
|
-
if (typeof
|
|
202
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|
|
200
203
|
sendJson(res, 200, { ok: false, error: 'missing file path' })
|
|
201
204
|
return
|
|
202
205
|
}
|
|
203
206
|
|
|
204
207
|
try {
|
|
205
|
-
const target = await fs.resolve(
|
|
208
|
+
const target = await fs.resolve(requestedPath)
|
|
206
209
|
const info = await fs.stat(target)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (size !== null && size > limit) {
|
|
210
|
-
sendJson(res, 200, { ok: false, error: 'file too large for explorer download' })
|
|
210
|
+
if (info === undefined || info.type !== 'file') {
|
|
211
|
+
sendJson(res, 200, { ok: false, error: 'file does not exist' })
|
|
211
212
|
return
|
|
212
213
|
}
|
|
213
|
-
const
|
|
214
|
-
const name = target.displayPath.split('/').pop()
|
|
215
|
-
|
|
214
|
+
const size = typeof info.size === 'number' && Number.isFinite(info.size) && info.size >= 0 ? info.size : 0
|
|
215
|
+
const name = target.displayPath.split('/').pop() || 'download'
|
|
216
|
+
const safeName = String(name).replace(/[\r\n"\\]/gu, '_').replace(/[^\x20-\x7e]/gu, '_') || 'download'
|
|
217
|
+
const encodedName = encodeURIComponent(String(name)).replace(/['()]/gu, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
218
|
+
|
|
219
|
+
res.writeHead(200, {
|
|
220
|
+
'content-type': guessMime(name),
|
|
221
|
+
'content-disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
|
222
|
+
'content-length': size,
|
|
223
|
+
'x-content-type-options': 'nosniff',
|
|
224
|
+
'cache-control': 'no-store',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Native streaming download: pipe the resolved host path straight to the
|
|
228
|
+
// HTTP response. No full-file buffering, no base64, no 64 MiB ceiling.
|
|
229
|
+
const stream = createReadStream(fs.processPath(target))
|
|
230
|
+
stream.on('error', (error) => {
|
|
231
|
+
if (res.headersSent) res.destroy(error)
|
|
232
|
+
else {
|
|
233
|
+
try {
|
|
234
|
+
sendJson(res, 500, { ok: false, error: `failed to stream file: ${error && typeof error.message === 'string' ? error.message : String(error)}` })
|
|
235
|
+
} catch {
|
|
236
|
+
res.destroy(error)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
stream.pipe(res)
|
|
216
241
|
} catch (error) {
|
|
217
242
|
sendJson(res, 200, { ok: false, error: error && typeof error.message === 'string' ? error.message : String(error) })
|
|
218
243
|
}
|