@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.
Files changed (4) hide show
  1. package/README.md +6 -4
  2. package/client.js +273 -98
  3. package/index.js +37 -12
  4. 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.5.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.5.0.tgz
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` returns a
80
- base64 data URL (64 MiB cap) that the browser triggers with `<a download>`.
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 'just now'
710
- if (s < 3600) return `${Math.floor(s / 60)}m ago`
711
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`
712
- if (s < 604800) return `${Math.floor(s / 86400)}d ago`
713
- if (s < 2592000) return `${Math.floor(s / 604800)}w ago`
714
- if (s < 31536000) return `${Math.floor(s / 2592000)}mo ago`
715
- return `${Math.floor(s / 31536000)}y ago`
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 GitGraphView(props) {
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 : 'Failed to load git log')
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 : 'Failed to load commit' } }))
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 (diff truncated)' : ''
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 : 'Failed to load diff' })
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' }, 'Loading changes…'))
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 || '(no 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
- `${files.length} changed file${files.length === 1 ? '' : 's'}`),
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' }, 'Loading…'))
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: 'Uncommitted changes',
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' }, 'Uncommitted changes'),
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' }, 'Working tree'),
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
- `${changes.length} changed file${changes.length === 1 ? '' : 's'} (vs HEAD)`),
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 || 'unknown'} · ${relTime(c.date)} · ${shortHash(c.hash)}`),
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' }, 'No commits yet'))
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' }, 'No data'))
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
- ? 'detached HEAD'
1105
- : status.branch + (status.unborn ? ' (no commits yet)' : ''))
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': 'Refresh graph',
1118
- title: 'Refresh',
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': 'Branch filter',
1293
+ 'aria-label': t('git.branchFilter'),
1124
1294
  onChange: (event) => setScope(event.target.value),
1125
1295
  },
1126
- React.createElement('option', { value: 'all' }, 'All branches'),
1127
- React.createElement('option', { value: 'current' }, 'Current branch'),
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, `${changes.length} change${changes.length === 1 ? '' : 's'}`),
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': 'Diff',
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' ? 'working tree' : shortHash(diff.hash)),
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': 'Close diff',
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 ? 'Loading…' : (diff.error || diff.text || '(no diff)')),
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 FileExplorerPage(props) {
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 : 'Failed to list files')
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 : 'Failed to read file' })
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
- api('download', { path: entry.path })
1347
- .then((raw) => {
1348
- if (!raw || raw.ok !== true) {
1349
- const message = raw && typeof raw.error === 'string' ? raw.error : 'Download failed'
1350
- setError(message)
1351
- return
1352
- }
1353
- try {
1354
- const link = document.createElement('a')
1355
- link.href = raw.dataUrl
1356
- link.download = entry.name
1357
- document.body.appendChild(link)
1358
- link.click()
1359
- link.remove()
1360
- } catch (err) {
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 : 'Delete failed')
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 || 'workspace root',
1568
+ title: currentPath || t('files.workspaceRoot'),
1401
1569
  onClick: startEditPath,
1402
- }, currentPath || 'workspace root')
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': 'Go to parent directory',
1415
- title: 'Up',
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': 'Refresh file list',
1422
- title: 'Refresh',
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' }, 'Loading...')
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' }, 'Empty folder')
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': `View ${entry.name}`,
1453
- title: 'View',
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': `Download ${entry.name}`,
1460
- title: 'Download',
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 ? `Confirm deleting ${entry.name}` : `Delete ${entry.name}`,
1467
- title: pendingDelete === entry.path ? 'Click again to delete' : 'Delete',
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': 'Files',
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': 'File preview',
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 || 'Preview'),
1500
- React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? `Image / ${formatSize(preview.size)}` : preview.binary ? 'Binary' : formatSize(preview.size)),
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': 'Image controls',
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': 'Zoom out',
1513
- title: 'Zoom out',
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': 'Reset image zoom',
1520
- title: 'Reset zoom',
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': 'Zoom in',
1528
- title: 'Zoom in',
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 ? 'Exit fullscreen' : 'Enter fullscreen',
1535
- title: imageFullscreen ? 'Exit fullscreen' : 'Fullscreen',
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': 'Close preview',
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
- ? 'Loading...'
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 || 'Image preview',
1733
+ alt: preview.name || t('files.imagePreview'),
1566
1734
  draggable: false,
1567
1735
  })
1568
1736
  )
1569
- : preview.tooLarge ? 'Image is too large to preview here' : '(No preview)'
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
- ? 'File is too large to preview here'
1574
- : preview.binary ? 'Binary file cannot be previewed' : '(No preview)',
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 FileExplorerGitPage(props) {
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': 'Git Graph',
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: 'Files',
1610
- group: 'Workspace',
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: 'Git Graph',
1618
- group: 'Workspace',
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 (data URL),
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
- if (req.method === 'POST') req = await readJson(req)
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 req.path !== 'string') {
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(req.path)
208
+ const target = await fs.resolve(requestedPath)
206
209
  const info = await fs.stat(target)
207
- const size = info && typeof info.size === 'number' ? info.size : null
208
- const limit = 64 * 1024 * 1024
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 bytes = await fs.readBytes(target, undefined, limit)
214
- const name = target.displayPath.split('/').pop()
215
- sendJson(res, 200, { ok: true, name, size, dataUrl: `data:${guessMime(name)};base64,${bytesToBase64(bytes)}` })
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },