@yiln-dsh/dsh-plugin-file-explorer 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -27
  2. package/client.js +325 -350
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -1,17 +1,17 @@
1
1
  # @yiln-dsh/dsh-plugin-file-explorer
2
2
 
3
- A DSH `dsh.bundle` that replaces the built-in **details** column of the Web
4
- GUI with a workspace file explorer.
3
+ A DSH `dsh.bundle` that contributes a workspace file explorer page to the
4
+ `@yiln-dsh/dsh-plugin-right-panel` host.
5
5
 
6
- - Replaces the built-in tool-details right column while the bundle row is
7
- mounted (it is restored automatically when the plugin is disabled/removed).
8
- - Auto-opens when a conversation (non-blank session) starts.
9
- - **Files** tab: lists the active session's workspace directory, with folder
6
+ - Registers two independent right-panel pages: **Files** and **Git Graph**. The
7
+ host owns the right-column layout, navigation rail, width, and collapse
8
+ behavior.
9
+ - **Files** page: lists the active session's workspace directory, with folder
10
10
  navigation, editable path input, parent/refresh buttons, file and folder
11
11
  icons, and file sizes. File rows reveal view / download / delete buttons on
12
12
  hover; delete uses a second-click confirm. Image View supports fullscreen,
13
13
  25%-400% zoom, wheel zoom, and drag-to-pan after zooming.
14
- - **Git Graph** tab: a vscode/le-git-graph style commit graph rendered from
14
+ - **Git Graph** page: a vscode/le-git-graph style commit graph rendered from
15
15
  the repository containing the current directory —
16
16
  - colored lane graph with commit dots and merge curves (all branches or the
17
17
  current branch, refreshable),
@@ -22,34 +22,26 @@ GUI with a workspace file explorer.
22
22
  A/M/D/R status badges,
23
23
  - click a file to open its diff patch (commit shows `git show`, working tree
24
24
  shows `git diff HEAD`) in a dialog.
25
- - The column is a real layout column: it squeezes the conversation and its
26
- width can be dragged between 300–520px with the native splitter handle.
27
- - The chosen width persists across sessions and page reloads (localStorage):
28
- the plugin captures the shell layout store actions when the root entry wires
29
- them, restores the saved width right after `openDetails()`, and saves the
30
- column width after every resizer drag.
31
- - **Collapse to icon bar**: the collapse button matches the DSH session
32
- menu's circular panel-toggle style, with the direction mirrored for the
33
- right-side column. Clicking it keeps a real 56px rail in the layout with
34
- **Files** and **Git Graph** icons; clicking either reopens that tab at the
35
- last chosen width. The rail is not a floating overlay, and the panel state
36
- is preserved.
25
+ - The host keeps a real 56px right-edge icon rail when collapsed. Other
26
+ right-panel pages can appear alongside Files and overflow into the host's
27
+ searchable More menu.
37
28
 
38
29
  ## Layout
39
30
 
40
31
  | File | Content |
41
32
  | --- | --- |
42
33
  | `index.js` | Host half: registers exact `/ _dsh/file-explorer/*` routes backed by the Host `fs`/`subprocess` services, plus read-only git routes (`/ _dsh/file-explorer/git-log`, `git-commit`, `git-diff`, `git-status`) that run `git` with machine-readable separators and return JSON only. |
43
- | `client.js` | Client half: a static DSH client module registered into the `details` slot; Files + Git Graph tabs. |
34
+ | `client.js` | Client half: registers independent keyed `right-panel.page` entries for Files and Git Graph. |
44
35
  | `cordis.patch.yml` | Composition patch that mounts the host row — declared with `inject: [webServer]`, so it activates only after the stock webserver service (`127.0.0.1`) is up. |
45
36
 
46
37
  ## Install
47
38
 
48
- The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.4.1`.
39
+ The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.5.1`.
49
40
 
50
- ### Local source directory
41
+ The right-panel package must be installed in the same `web` profile:
51
42
 
52
43
  ```bash
44
+ dsh plugin --profile web add file:/path/to/dsh-plugin-right-panel
53
45
  dsh plugin --profile web add file:/path/to/dsh-plugin-file-explorer
54
46
  ```
55
47
 
@@ -61,7 +53,7 @@ pnpm pack
61
53
  ```
62
54
 
63
55
  ```bash
64
- dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.4.1.tgz
56
+ dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.5.1.tgz
65
57
  ```
66
58
 
67
59
  ### npm package
@@ -98,7 +90,6 @@ apply the new profile composition.
98
90
  sentinel) and file paths against a non-option, non-control-character check.
99
91
  - Rows have a fixed 34px height, so the hover action swap never changes the
100
92
  row height; directories reveal a delete-only action set on hover.
101
- - The browser module uses the `details` slot (scope: session), registering at
102
- priority `-6` to shadow the built-in tool-details occupant (priority `0`),
103
- the `layout` service (`openDetails` / `closeDetails`), the standard
104
- `useSessions` hook, and its own stylesheet.
93
+ - The browser module registers `file-explorer.files` and
94
+ `file-explorer.git` into the keyed `right-panel.page` Slot and uses the
95
+ standard session `useSessions` hook.
package/client.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * dsh-plugin-file-explorer — browser bundle (static DSH client module).
3
3
  *
4
- * Renders the file explorer into the `details` slot (right layout column),
5
- * replacing the native tool-details panel while running. The column squeezes
6
- * the conversation and keeps the native resizer handle.
4
+ * Registers the file explorer as a page in the `right-panel` host. The host
5
+ * owns the right layout column, width, navigation rail, and page switching.
7
6
  *
8
- * Two views behind the header tabs:
7
+ * Two views behind the page's local tabs:
9
8
  * - Files: directory browser (list / read / download / delete via the
10
9
  * /_dsh/file-explorer API).
11
10
  * - Git Graph: a vscode/git-graph style commit DAG with colored lanes, ref
@@ -20,12 +19,181 @@ window.__ModuleLoader__.load({
20
19
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
21
20
  let React = require("react");
22
21
 
23
- const inject = ["slots", "layout"];
22
+ const inject = ["slots", "rightPanel"];
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
+ }
24
184
 
25
185
  function apply(ctx) {
26
186
  const slots = ctx.get('slots')
27
- const layout = ctx.get('layout')
28
- if (slots === undefined) return
187
+ const rightPanel = ctx.get('rightPanel')
188
+ if (slots === undefined || rightPanel === undefined) return
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);
29
197
 
30
198
  const style = document.createElement("style");
31
199
  style.id = "dsh-plugin-file-explorer-style";
@@ -77,35 +245,6 @@ window.__ModuleLoader__.load({
77
245
  .dsh-fe-icon:hover {
78
246
  background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2, #e7e5e4));
79
247
  }
80
- .dsh-fe-tabs {
81
- display: flex;
82
- gap: 2px;
83
- padding: 8px 12px 0;
84
- }
85
- .dsh-fe-tab {
86
- flex: 1 1 0;
87
- height: 26px;
88
- border: 0;
89
- border-radius: 6px;
90
- background: transparent;
91
- color: var(--dsw-alias-label-secondary, #4b5563);
92
- cursor: pointer;
93
- font: inherit;
94
- font-size: 12px;
95
- display: inline-flex;
96
- align-items: center;
97
- justify-content: center;
98
- gap: 6px;
99
- }
100
- .dsh-fe-tab:hover {
101
- background: var(--dsw-alias-bg-layer-2, #e7e5e4);
102
- }
103
- .dsh-fe-tab-active,
104
- .dsh-fe-tab-active:hover {
105
- background: var(--dsw-alias-bg-layer-2, #e7e5e4);
106
- color: var(--dsw-alias-label-primary, #111827);
107
- font-weight: 600;
108
- }
109
248
  .dsh-fe-path {
110
249
  min-width: 0;
111
250
  margin: 8px 12px 0;
@@ -587,154 +726,19 @@ window.__ModuleLoader__.load({
587
726
  font-size: 12px;
588
727
  unicode-bidi: plaintext;
589
728
  }
590
- .dsh-fe-rail {
729
+ .dsh-fe-page {
591
730
  height: 100%;
592
- width: 100%;
593
- box-sizing: border-box;
731
+ min-height: 0;
594
732
  display: flex;
595
733
  flex-direction: column;
596
- align-items: center;
597
- gap: 6px;
598
- padding: 10px 6px;
734
+ box-sizing: border-box;
735
+ overflow: hidden;
599
736
  background: var(--dsw-specific-sidebar-fill, var(--dsw-alias-bg-base, #f5f5f4));
600
- border-left: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
601
- }
602
- /* While collapsed, keep a real RAIL_WIDTH column in the layout (like the left
603
- sidebar's 56px rail) instead of a floating overlay. This !important rule
604
- overrides React's inline grid-template-columns on the app frame; the data
605
- attribute is added by applyRail() and removed on expand/unload. */
606
- [data-dsh-fe-rail] {
607
- grid-template-columns: var(--dsh-fe-rail-sidebar, 280px) minmax(0, 1fr) 56px !important;
608
- }
609
- .dsh-fe-rail-button {
610
- width: 36px;
611
- height: 36px;
612
- border: 0;
613
- border-radius: 8px;
614
- background: transparent;
615
- color: var(--dsw-alias-label-secondary, #4b5563);
616
- cursor: pointer;
617
- display: inline-flex;
618
- align-items: center;
619
- justify-content: center;
620
- }
621
- .dsh-fe-rail-button:hover {
622
- background: var(--dsw-alias-bg-layer-2, #e7e5e4);
623
- color: var(--dsw-alias-brand-primary, #4f46e5);
737
+ color: var(--dsw-alias-label-primary, #111827);
624
738
  }
625
739
  `;
626
740
  document.head.append(style);
627
741
 
628
- // --- details-column width persistence ---------------------------------
629
- // The shell's layout store is transient: openDetails() resets the column
630
- // to its 360px contract default on every mount and session switch. The
631
- // bound store actions reach the LayoutController after every client
632
- // module has applied (the root entry's inject hook fires on first
633
- // render and again on re-register), so wrapping attachPanels on the
634
- // prototype captures them reliably. setDetails then restores the last
635
- // user-chosen width, and a document-level pointerup listener persists
636
- // the column width after each resizer drag.
637
- const WIDTH_KEY = "dsh-plugin-file-explorer.detailsWidth";
638
- const WIDTH_MIN = 300; // the layout contract's clamp floor
639
- let panelActions = null;
640
- let savedWidth = 0;
641
- try { savedWidth = Number(window.localStorage.getItem(WIDTH_KEY)) || 0 } catch (_e) { }
642
-
643
- const findDetailsColumn = () => {
644
- // The details column's own class is the reliable anchor: the drag
645
- // handle's previousElementSibling is the sidebar handle or the
646
- // overlay layer, never the column itself.
647
- const byClass = document.querySelector('[class*="detailsCol"]')
648
- if (byClass !== null) return byClass
649
- const handle = document.querySelector('[data-side="details"]')
650
- if (handle !== null && handle.previousElementSibling !== null) return handle.previousElementSibling
651
- return null
652
- }
653
- const persistWidth = () => {
654
- const column = findDetailsColumn()
655
- if (column === null) return
656
- const width = Math.round(column.getBoundingClientRect().width)
657
- if (width < WIDTH_MIN) return
658
- savedWidth = width
659
- try { window.localStorage.setItem(WIDTH_KEY, String(width)) } catch (_e) { }
660
- }
661
- const onDocumentPointerUp = () => { persistWidth() }
662
- document.addEventListener("pointerup", onDocumentPointerUp, true)
663
- ctx.effect(() => () => document.removeEventListener("pointerup", onDocumentPointerUp, true))
664
-
665
- if (layout !== undefined) {
666
- const proto = Object.getPrototypeOf(layout)
667
- if (proto !== null && typeof proto.attachPanels === "function") {
668
- const originalAttach = proto.attachPanels
669
- proto.attachPanels = function (actions) {
670
- panelActions = actions
671
- return originalAttach.call(this, actions)
672
- }
673
- ctx.effect(() => () => { proto.attachPanels = originalAttach })
674
- }
675
- }
676
- const restoreWidth = () => {
677
- if (savedWidth < WIDTH_MIN) return
678
- if (panelActions === null || typeof panelActions.setDetails !== "function") return
679
- try { panelActions.setDetails(savedWidth) } catch (_e) { }
680
- }
681
-
682
- // --- collapsed rail as a real layout column --------------------------
683
- // The layout contract gives the sidebar a 56px rail when closed but
684
- // closes the details column to 0px (no rail). To collapse like the left
685
- // sidebar, keep the store closed (no drag handle / border) and force the
686
- // frame's third grid track to RAIL_WIDTH via an !important stylesheet
687
- // rule keyed on a data attribute we own. CSS !important beats React's
688
- // inline grid style, so there is no fight with React re-renders: set the
689
- // attribute to collapse, remove it to expand. A MutationObserver only
690
- // refreshes the sidebar-width variable (the first track) when the app
691
- // re-renders the frame.
692
- const RAIL_WIDTH = 56;
693
- let railActive = false;
694
- const frameOfDetails = () => {
695
- const column = findDetailsColumn();
696
- if (column === null || column.parentElement === null) return null;
697
- return column.parentElement;
698
- };
699
- const syncRailVar = () => {
700
- const frame = frameOfDetails();
701
- if (frame === null) return;
702
- const sideCol = frame.children.length > 0 ? frame.children[0] : null;
703
- const sidebarPx = sideCol !== null ? Math.round(sideCol.getBoundingClientRect().width) : 280;
704
- try { frame.style.setProperty("--dsh-fe-rail-sidebar", `${sidebarPx}px`) } catch (_e) { }
705
- };
706
- const applyRail = () => {
707
- const frame = frameOfDetails();
708
- if (frame === null) return;
709
- if (railActive) {
710
- frame.setAttribute("data-dsh-fe-rail", "");
711
- syncRailVar();
712
- } else {
713
- frame.removeAttribute("data-dsh-fe-rail");
714
- }
715
- };
716
- const releaseRail = () => {
717
- railActive = false;
718
- applyRail();
719
- };
720
- let railObserver = null;
721
- if (typeof MutationObserver === "function") {
722
- railObserver = new MutationObserver(() => {
723
- if (railActive) syncRailVar();
724
- });
725
- ctx.effect(() => {
726
- const frame = frameOfDetails();
727
- if (frame !== null) railObserver.observe(frame, { attributes: true, attributeFilter: ["style"] });
728
- return () => {
729
- railObserver?.disconnect();
730
- const frame = frameOfDetails();
731
- if (frame !== null) frame.removeAttribute("data-dsh-fe-rail");
732
- railActive = false;
733
- };
734
- });
735
- }
736
- // -----------------------------------------------------------------------
737
-
738
742
  async function api(method, payload) {
739
743
  const options = { headers: { "content-type": "application/json" } };
740
744
  options.method = "POST";
@@ -868,16 +872,16 @@ window.__ModuleLoader__.load({
868
872
  return STATUS_COLORS[letter] || '#6b7280'
869
873
  }
870
874
 
871
- function relTime(epochSeconds) {
875
+ function relTime(epochSeconds, t) {
872
876
  if (typeof epochSeconds !== 'number' || !Number.isFinite(epochSeconds) || epochSeconds <= 0) return ''
873
877
  const s = Math.max(0, Date.now() / 1000 - epochSeconds)
874
- if (s < 60) return 'just now'
875
- if (s < 3600) return `${Math.floor(s / 60)}m ago`
876
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`
877
- if (s < 604800) return `${Math.floor(s / 86400)}d ago`
878
- if (s < 2592000) return `${Math.floor(s / 604800)}w ago`
879
- if (s < 31536000) return `${Math.floor(s / 2592000)}mo ago`
880
- 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) })
881
885
  }
882
886
 
883
887
  function shortHash(hash) {
@@ -1092,7 +1096,8 @@ window.__ModuleLoader__.load({
1092
1096
  // line, the commit lane rows with an expandable per-commit detail, and a
1093
1097
  // diff modal. Data comes entirely from the /_dsh/file-explorer/git-*
1094
1098
  // JSON API.
1095
- function GitGraphView(props) {
1099
+ function createGitGraphView(t) {
1100
+ return function GitGraphView(props) {
1096
1101
  const api = props.api
1097
1102
  const cwd = props.cwd
1098
1103
  const [scope, setScope] = React.useState('all')
@@ -1120,7 +1125,7 @@ window.__ModuleLoader__.load({
1120
1125
  if (raw && raw.ok === true) {
1121
1126
  setLog(raw)
1122
1127
  } else {
1123
- 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'))
1124
1129
  }
1125
1130
  })
1126
1131
  .catch((err) => {
@@ -1146,7 +1151,7 @@ window.__ModuleLoader__.load({
1146
1151
  if (raw && raw.ok === true) {
1147
1152
  setDetails((prev) => ({ ...prev, [selected]: raw }))
1148
1153
  } else {
1149
- 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') } }))
1150
1155
  }
1151
1156
  })
1152
1157
  .catch((err) => {
@@ -1165,10 +1170,10 @@ window.__ModuleLoader__.load({
1165
1170
  api('git-diff', { path: cwd, hash, file: file.path })
1166
1171
  .then((raw) => {
1167
1172
  if (raw && raw.ok === true) {
1168
- const note = raw.truncated ? '\n\n (diff truncated)' : ''
1173
+ const note = raw.truncated ? '\n\n' + t('git.diffTruncated') : ''
1169
1174
  setDiff({ title: file.path, hash, loading: false, text: (raw.patch || '') + note, error: null })
1170
1175
  } else {
1171
- 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') })
1172
1177
  }
1173
1178
  })
1174
1179
  .catch((err) => {
@@ -1182,7 +1187,7 @@ window.__ModuleLoader__.load({
1182
1187
  const renderCommitDetail = (hash, d) => {
1183
1188
  if (d === undefined) {
1184
1189
  return React.createElement('div', { className: 'dsh-git-detail' },
1185
- React.createElement('div', { className: 'dsh-git-files-title' }, 'Loading changes…'))
1190
+ React.createElement('div', { className: 'dsh-git-files-title' }, t('git.loadingChanges')))
1186
1191
  }
1187
1192
  if (d.error) {
1188
1193
  return React.createElement('div', { className: 'dsh-git-detail' },
@@ -1190,9 +1195,9 @@ window.__ModuleLoader__.load({
1190
1195
  }
1191
1196
  const files = Array.isArray(d.files) ? d.files : []
1192
1197
  return React.createElement('div', { className: 'dsh-git-detail' },
1193
- React.createElement('div', { className: 'dsh-git-msg' }, d.message || '(no message)'),
1198
+ React.createElement('div', { className: 'dsh-git-msg' }, d.message || t('git.noMessage')),
1194
1199
  React.createElement('div', { className: 'dsh-git-files-title' },
1195
- `${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 })),
1196
1201
  files.length === 0 ? null : files.map((f, i) =>
1197
1202
  React.createElement(GitFileRow, { key: i, file: f, onOpen: (file) => openDiff(hash, file) })),
1198
1203
  )
@@ -1200,7 +1205,7 @@ window.__ModuleLoader__.load({
1200
1205
 
1201
1206
  const rows = []
1202
1207
  if (loading) {
1203
- 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')))
1204
1209
  } else if (error) {
1205
1210
  rows.push(React.createElement('div', { key: 'error', className: 'dsh-fe-status dsh-fe-status-error' }, error))
1206
1211
  } else if (graph !== null) {
@@ -1214,21 +1219,21 @@ window.__ModuleLoader__.load({
1214
1219
  className: 'dsh-git-row',
1215
1220
  onClick: () => toggle('WORKING'),
1216
1221
  role: 'button',
1217
- title: 'Uncommitted changes',
1222
+ title: t('git.uncommitted'),
1218
1223
  },
1219
1224
  React.createElement(WorkTreeCell, { lane: first !== null ? first.lane : 0, connectorColor }),
1220
1225
  React.createElement('div', { className: 'dsh-git-main' },
1221
1226
  React.createElement('div', { className: 'dsh-git-line1' },
1222
- React.createElement('span', { className: 'dsh-git-subject' }, 'Uncommitted changes'),
1227
+ React.createElement('span', { className: 'dsh-git-subject' }, t('git.uncommitted')),
1223
1228
  React.createElement('span', { className: 'dsh-git-pill dsh-git-pill-branch' }, String(changes.length)),
1224
1229
  ),
1225
- React.createElement('div', { className: 'dsh-git-line2' }, 'Working tree'),
1230
+ React.createElement('div', { className: 'dsh-git-line2' }, t('git.workingTree')),
1226
1231
  ),
1227
1232
  ),
1228
1233
  selected === 'WORKING'
1229
1234
  ? React.createElement('div', { className: 'dsh-git-detail' },
1230
1235
  React.createElement('div', { className: 'dsh-git-files-title' },
1231
- `${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 })),
1232
1237
  changes.map((f, i) =>
1233
1238
  React.createElement(GitFileRow, { key: i, file: f, onOpen: (file) => openDiff('WORKING', file) })),
1234
1239
  )
@@ -1251,23 +1256,23 @@ window.__ModuleLoader__.load({
1251
1256
  React.createElement('span', { className: 'dsh-git-subject' }, c.subject),
1252
1257
  ),
1253
1258
  React.createElement('div', { className: 'dsh-git-line2' },
1254
- `${c.author || 'unknown'} · ${relTime(c.date)} · ${shortHash(c.hash)}`),
1259
+ `${c.author || t('git.authorUnknown')} · ${relTime(c.date, t)} · ${shortHash(c.hash)}`),
1255
1260
  ),
1256
1261
  ),
1257
1262
  selected === c.hash ? renderCommitDetail(c.hash, details[c.hash]) : null,
1258
1263
  ))
1259
1264
  })
1260
1265
  if (rows.length === 0) {
1261
- 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')))
1262
1267
  }
1263
1268
  } else {
1264
- 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')))
1265
1270
  }
1266
1271
 
1267
1272
  const branchLabel = status
1268
1273
  ? (status.branch === null
1269
- ? 'detached HEAD'
1270
- : status.branch + (status.unborn ? ' (no commits yet)' : ''))
1274
+ ? t('git.detachedHead')
1275
+ : status.branch + (status.unborn ? t('git.noCommitsYetSuffix') : ''))
1271
1276
  : null
1272
1277
  const upstreamBits = []
1273
1278
  if (status && status.ahead > 0) upstreamBits.push('↑' + status.ahead)
@@ -1279,17 +1284,17 @@ window.__ModuleLoader__.load({
1279
1284
  type: 'button',
1280
1285
  className: 'dsh-fe-toolbutton',
1281
1286
  onClick: reload,
1282
- 'aria-label': 'Refresh graph',
1283
- title: 'Refresh',
1287
+ 'aria-label': t('git.refreshGraph'),
1288
+ title: t('files.refresh'),
1284
1289
  }, svgIcon(refreshIcon, 15)),
1285
1290
  React.createElement('select', {
1286
1291
  className: 'dsh-git-scope',
1287
1292
  value: scope,
1288
- 'aria-label': 'Branch filter',
1293
+ 'aria-label': t('git.branchFilter'),
1289
1294
  onChange: (event) => setScope(event.target.value),
1290
1295
  },
1291
- React.createElement('option', { value: 'all' }, 'All branches'),
1292
- 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')),
1293
1298
  ),
1294
1299
  ),
1295
1300
  branchLabel !== null
@@ -1297,7 +1302,7 @@ window.__ModuleLoader__.load({
1297
1302
  svgIcon(branchIcon, 12),
1298
1303
  React.createElement('span', { className: 'dsh-git-info-name' }, branchLabel),
1299
1304
  upstreamBits.length > 0 ? React.createElement('span', null, upstreamBits.join(' ')) : null,
1300
- 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 })),
1301
1306
  )
1302
1307
  : null,
1303
1308
  React.createElement('div', { className: 'dsh-git-list' }, rows),
@@ -1309,29 +1314,31 @@ window.__ModuleLoader__.load({
1309
1314
  React.createElement('div', {
1310
1315
  className: 'dsh-fe-modal',
1311
1316
  role: 'dialog',
1312
- 'aria-label': 'Diff',
1317
+ 'aria-label': t('git.diff'),
1313
1318
  onClick: (event) => event.stopPropagation(),
1314
1319
  },
1315
1320
  React.createElement('div', { className: 'dsh-fe-modal-head' },
1316
1321
  React.createElement('div', { className: 'dsh-fe-modal-title' }, diff.title),
1317
1322
  React.createElement('div', { className: 'dsh-fe-modal-meta' },
1318
- diff.hash === 'WORKING' ? 'working tree' : shortHash(diff.hash)),
1323
+ diff.hash === 'WORKING' ? t('git.workingTreeLabel') : shortHash(diff.hash)),
1319
1324
  React.createElement('button', {
1320
1325
  type: 'button',
1321
1326
  className: 'dsh-fe-icon',
1322
1327
  onClick: () => setDiff(null),
1323
- 'aria-label': 'Close diff',
1328
+ 'aria-label': t('git.closeDiff'),
1324
1329
  }, svgIcon(closeIcon, 14)),
1325
1330
  ),
1326
1331
  React.createElement('div', { className: 'dsh-fe-modal-body' },
1327
- diff.loading ? 'Loading…' : (diff.error || diff.text || '(no diff)')),
1332
+ diff.loading ? t('git.loading') : (diff.error || diff.text || t('git.noDiff'))),
1328
1333
  ),
1329
1334
  )
1330
1335
  : null,
1331
1336
  )
1332
1337
  }
1338
+ }
1333
1339
 
1334
- function FileExplorerPanel(props) {
1340
+ function createFileExplorerPage(t) {
1341
+ return function FileExplorerPage(props) {
1335
1342
  if (typeof props.useSessions !== 'function') return null
1336
1343
 
1337
1344
  const sessionId = props.useSessions((state) => {
@@ -1364,23 +1371,11 @@ window.__ModuleLoader__.load({
1364
1371
  const [imageDragging, setImageDragging] = React.useState(false)
1365
1372
  const imageDrag = React.useRef(null)
1366
1373
  const [pendingDelete, setPendingDelete] = React.useState(null)
1367
- const [view, setView] = React.useState('files')
1368
- const [collapsed, setCollapsed] = React.useState(false)
1369
-
1370
- React.useEffect(() => {
1371
- if (sessionId === undefined) return
1372
- setCollapsed(false)
1373
- if (layout !== undefined) {
1374
- try { layout.openDetails() } catch (_e) { }
1375
- }
1376
- // Same-tick store updates batch in React, so restoring right after
1377
- // openDetails() replaces the 360px default without a visible jump.
1378
- restoreWidth()
1379
- }, [sessionId])
1380
1374
 
1381
1375
  React.useEffect(() => {
1382
1376
  if (sessionId === undefined) {
1383
- releaseRail()
1377
+ setParentPath(null)
1378
+ setRequestPath('')
1384
1379
  return
1385
1380
  }
1386
1381
  setParentPath(null)
@@ -1408,7 +1403,7 @@ window.__ModuleLoader__.load({
1408
1403
  setCurrentPath(requestPath)
1409
1404
  setParentPath(null)
1410
1405
  setEntries([])
1411
- setError(raw && typeof raw.error === 'string' ? raw.error : 'Failed to list files')
1406
+ setError(raw && typeof raw.error === 'string' ? raw.error : t('files.listFailed'))
1412
1407
  }
1413
1408
  })
1414
1409
  .catch((err) => {
@@ -1432,34 +1427,6 @@ window.__ModuleLoader__.load({
1432
1427
  return aDir - bDir || a.name.localeCompare(b.name)
1433
1428
  })
1434
1429
  const isRoot = parentPath === null || parentPath === currentPath
1435
- // Collapse into a slim icon rail that keeps a real 56px column in
1436
- // the layout (like the left sidebar's rail): the details store stays
1437
- // closed (no drag handle / border), and applyRail() toggles the
1438
- // [data-dsh-fe-rail] attribute so the !important stylesheet rule
1439
- // overrides the frame's third grid track. A MutationObserver keeps
1440
- // the sidebar-width variable fresh across app re-renders.
1441
- const collapse = () => {
1442
- setCollapsed(true)
1443
- railActive = true
1444
- closePreview()
1445
- setPendingDelete(null)
1446
- setEditingPath(false)
1447
- if (layout !== undefined) {
1448
- try { layout.closeDetails() } catch (_e) { }
1449
- }
1450
- applyRail()
1451
- }
1452
- const expand = (nextView) => {
1453
- releaseRail()
1454
- setCollapsed(false)
1455
- setView(nextView)
1456
- if (layout !== undefined) {
1457
- try { layout.openDetails() } catch (_e) { }
1458
- }
1459
- // Same-tick store updates batch in React, so restoring right after
1460
- // openDetails() replaces the 360px default without a visible jump.
1461
- restoreWidth()
1462
- }
1463
1430
  const reload = () => { setPendingDelete(null); setReloadTick((tick) => tick + 1) }
1464
1431
  const startEditPath = () => {
1465
1432
  setDraftPath(currentPath)
@@ -1538,7 +1505,7 @@ window.__ModuleLoader__.load({
1538
1505
  setPreview(null)
1539
1506
  api('read', { path: entry.path })
1540
1507
  .then((raw) => {
1541
- 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') })
1542
1509
  })
1543
1510
  .catch((err) => {
1544
1511
  setPreview({ ok: false, error: err && typeof err.message === 'string' ? err.message : String(err) })
@@ -1551,7 +1518,7 @@ window.__ModuleLoader__.load({
1551
1518
  api('download', { path: entry.path })
1552
1519
  .then((raw) => {
1553
1520
  if (!raw || raw.ok !== true) {
1554
- const message = raw && typeof raw.error === 'string' ? raw.error : 'Download failed'
1521
+ const message = raw && typeof raw.error === 'string' ? raw.error : t('files.downloadFailed')
1555
1522
  setError(message)
1556
1523
  return
1557
1524
  }
@@ -1578,7 +1545,7 @@ window.__ModuleLoader__.load({
1578
1545
  api('delete', { path: entry.path })
1579
1546
  .then((raw) => {
1580
1547
  if (!raw || raw.ok !== true) {
1581
- setError(raw && typeof raw.error === 'string' ? raw.error : 'Delete failed')
1548
+ setError(raw && typeof raw.error === 'string' ? raw.error : t('files.deleteFailed'))
1582
1549
  return
1583
1550
  }
1584
1551
  reload()
@@ -1602,9 +1569,9 @@ window.__ModuleLoader__.load({
1602
1569
  : React.createElement('button', {
1603
1570
  type: 'button',
1604
1571
  className: 'dsh-fe-path',
1605
- title: currentPath || 'workspace root',
1572
+ title: currentPath || t('files.workspaceRoot'),
1606
1573
  onClick: startEditPath,
1607
- }, currentPath || 'workspace root')
1574
+ }, currentPath || t('files.workspaceRoot'))
1608
1575
 
1609
1576
  const filesView = React.createElement(React.Fragment, null,
1610
1577
  pathControl,
@@ -1616,24 +1583,24 @@ window.__ModuleLoader__.load({
1616
1583
  onClick: () => {
1617
1584
  if (parentPath !== null && parentPath !== currentPath) setRequestPath(parentPath)
1618
1585
  },
1619
- 'aria-label': 'Go to parent directory',
1620
- title: 'Up',
1586
+ 'aria-label': t('files.goUp'),
1587
+ title: t('files.up'),
1621
1588
  }, svgIcon(upIcon, 15)),
1622
1589
  React.createElement('button', {
1623
1590
  type: 'button',
1624
1591
  className: 'dsh-fe-toolbutton',
1625
1592
  onClick: reload,
1626
- 'aria-label': 'Refresh file list',
1627
- title: 'Refresh',
1593
+ 'aria-label': t('files.refreshList'),
1594
+ title: t('files.refresh'),
1628
1595
  }, svgIcon(refreshIcon, 15)),
1629
1596
  ),
1630
1597
  React.createElement('div', { className: 'dsh-fe-list' },
1631
1598
  loading
1632
- ? React.createElement('div', { className: 'dsh-fe-status' }, 'Loading...')
1599
+ ? React.createElement('div', { className: 'dsh-fe-status' }, t('files.loading'))
1633
1600
  : error
1634
1601
  ? React.createElement('div', { className: 'dsh-fe-status dsh-fe-status-error' }, error)
1635
1602
  : sorted.length === 0
1636
- ? React.createElement('div', { className: 'dsh-fe-status' }, 'Empty folder')
1603
+ ? React.createElement('div', { className: 'dsh-fe-status' }, t('files.empty'))
1637
1604
  : sorted.map((entry) => {
1638
1605
  const isDir = entry.type === 'directory'
1639
1606
  return React.createElement('div', {
@@ -1654,22 +1621,22 @@ window.__ModuleLoader__.load({
1654
1621
  type: 'button',
1655
1622
  className: 'dsh-fe-action',
1656
1623
  onClick: (event) => { event.stopPropagation(); openPreview(entry) },
1657
- 'aria-label': `View ${entry.name}`,
1658
- title: 'View',
1624
+ 'aria-label': t('files.viewFile', { name: entry.name }),
1625
+ title: t('files.view'),
1659
1626
  }, svgIcon(eyeIcon, 14)),
1660
1627
  !isDir && React.createElement('button', {
1661
1628
  type: 'button',
1662
1629
  className: 'dsh-fe-action',
1663
1630
  onClick: (event) => { event.stopPropagation(); downloadFile(entry) },
1664
- 'aria-label': `Download ${entry.name}`,
1665
- title: 'Download',
1631
+ 'aria-label': t('files.downloadFile', { name: entry.name }),
1632
+ title: t('files.download'),
1666
1633
  }, svgIcon(downloadIcon, 14)),
1667
1634
  React.createElement('button', {
1668
1635
  type: 'button',
1669
1636
  className: pendingDelete === entry.path ? 'dsh-fe-action dsh-fe-action-confirm' : 'dsh-fe-action dsh-fe-action-danger',
1670
1637
  onClick: (event) => { event.stopPropagation(); requestDelete(entry) },
1671
- 'aria-label': pendingDelete === entry.path ? `Confirm deleting ${entry.name}` : `Delete ${entry.name}`,
1672
- title: pendingDelete === entry.path ? 'Click again to delete' : 'Delete',
1638
+ 'aria-label': pendingDelete === entry.path ? t('files.confirmDelete', { name: entry.name }) : t('files.deleteFile', { name: entry.name }),
1639
+ title: pendingDelete === entry.path ? t('files.clickAgain') : t('files.delete'),
1673
1640
  }, svgIcon(trashIcon, 14)),
1674
1641
  ),
1675
1642
  ),
@@ -1678,65 +1645,13 @@ window.__ModuleLoader__.load({
1678
1645
  ),
1679
1646
  )
1680
1647
 
1681
- // Collapsed: a slim icon rail (file / git) that keeps a real 56px
1682
- // column in the layout (the [data-dsh-fe-rail] !important grid rule;
1683
- // see applyRail above), mirroring the left sidebar's collapsed rail.
1684
- // Placed after every handler declaration: a collapsed render must not
1685
- // return early and leave `expand` uninitialized (TDZ error on click).
1686
- if (collapsed) {
1687
- return React.createElement('div', {
1688
- className: 'dsh-fe-rail',
1689
- role: 'region',
1690
- 'aria-label': 'File explorer (collapsed)',
1691
- },
1692
- React.createElement('button', {
1693
- type: 'button',
1694
- className: 'dsh-fe-rail-button',
1695
- onClick: () => expand('files'),
1696
- title: 'Files',
1697
- 'aria-label': 'Open file explorer',
1698
- }, svgIcon(folderIcon, 16)),
1699
- React.createElement('button', {
1700
- type: 'button',
1701
- className: 'dsh-fe-rail-button',
1702
- onClick: () => expand('git'),
1703
- title: 'Git Graph',
1704
- 'aria-label': 'Open git graph',
1705
- }, svgIcon(branchIcon, 16)),
1706
- )
1707
- }
1708
-
1709
1648
  return React.createElement(React.Fragment, null,
1710
1649
  React.createElement('div', {
1711
- className: 'dsh-fe-panel',
1650
+ className: 'dsh-fe-page',
1712
1651
  role: 'region',
1713
- 'aria-label': 'File explorer',
1652
+ 'aria-label': t('files.title'),
1714
1653
  },
1715
- React.createElement('div', { className: 'dsh-fe-header' },
1716
- React.createElement('button', {
1717
- type: 'button',
1718
- className: 'dsh-fe-icon',
1719
- onClick: collapse,
1720
- 'aria-label': 'Collapse file explorer into icon bar',
1721
- title: 'Collapse',
1722
- }, collapseIcon),
1723
- React.createElement('div', { className: 'dsh-fe-title' }, view === 'git' ? 'Git Graph' : 'Files'),
1724
- ),
1725
- React.createElement('div', { className: 'dsh-fe-tabs' },
1726
- React.createElement('button', {
1727
- type: 'button',
1728
- className: 'dsh-fe-tab' + (view === 'files' ? ' dsh-fe-tab-active' : ''),
1729
- onClick: () => setView('files'),
1730
- }, svgIcon(folderIcon, 13), 'Files'),
1731
- React.createElement('button', {
1732
- type: 'button',
1733
- className: 'dsh-fe-tab' + (view === 'git' ? ' dsh-fe-tab-active' : ''),
1734
- onClick: () => setView('git'),
1735
- }, svgIcon(branchIcon, 13), 'Git Graph'),
1736
- ),
1737
- view === 'git'
1738
- ? React.createElement(GitGraphView, { api, cwd })
1739
- : filesView,
1654
+ filesView,
1740
1655
  ),
1741
1656
  preview &&
1742
1657
  React.createElement('div', {
@@ -1746,50 +1661,50 @@ window.__ModuleLoader__.load({
1746
1661
  React.createElement('div', {
1747
1662
  className: preview.kind === 'image' && imageFullscreen ? 'dsh-fe-modal dsh-fe-modal-fullscreen' : 'dsh-fe-modal',
1748
1663
  role: 'dialog',
1749
- 'aria-label': 'File preview',
1664
+ 'aria-label': t('files.previewDialog'),
1750
1665
  'aria-modal': true,
1751
1666
  tabIndex: -1,
1752
1667
  onKeyDown: handlePreviewKeyDown,
1753
1668
  onClick: (event) => event.stopPropagation(),
1754
1669
  },
1755
1670
  React.createElement('div', { className: 'dsh-fe-modal-head' },
1756
- React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || 'Preview'),
1757
- React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? `Image / ${formatSize(preview.size)}` : preview.binary ? 'Binary' : formatSize(preview.size)),
1671
+ React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || t('files.preview')),
1672
+ 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)),
1758
1673
  preview.kind === 'image' && preview.dataUrl
1759
1674
  ? React.createElement('div', {
1760
1675
  className: 'dsh-fe-image-tools',
1761
1676
  role: 'toolbar',
1762
- 'aria-label': 'Image controls',
1677
+ 'aria-label': t('files.imageControls'),
1763
1678
  },
1764
1679
  React.createElement('button', {
1765
1680
  type: 'button',
1766
1681
  className: 'dsh-fe-icon dsh-fe-image-tool',
1767
1682
  disabled: imageScale <= 0.25,
1768
1683
  onClick: () => changeImageZoom(-0.25),
1769
- 'aria-label': 'Zoom out',
1770
- title: 'Zoom out',
1684
+ 'aria-label': t('files.zoomOut'),
1685
+ title: t('files.zoomOut'),
1771
1686
  }, svgIcon(zoomOutIcon, 14)),
1772
1687
  React.createElement('button', {
1773
1688
  type: 'button',
1774
1689
  className: 'dsh-fe-icon dsh-fe-image-tool',
1775
1690
  onClick: resetImageZoom,
1776
- 'aria-label': 'Reset image zoom',
1777
- title: 'Reset zoom',
1691
+ 'aria-label': t('files.resetImageZoom'),
1692
+ title: t('files.resetZoom'),
1778
1693
  }, React.createElement('span', { className: 'dsh-fe-image-zoom-label' }, `${Math.round(imageScale * 100)}%`)),
1779
1694
  React.createElement('button', {
1780
1695
  type: 'button',
1781
1696
  className: 'dsh-fe-icon dsh-fe-image-tool',
1782
1697
  disabled: imageScale >= 4,
1783
1698
  onClick: () => changeImageZoom(0.25),
1784
- 'aria-label': 'Zoom in',
1785
- title: 'Zoom in',
1699
+ 'aria-label': t('files.zoomIn'),
1700
+ title: t('files.zoomIn'),
1786
1701
  }, svgIcon(zoomInIcon, 14)),
1787
1702
  React.createElement('button', {
1788
1703
  type: 'button',
1789
1704
  className: 'dsh-fe-icon dsh-fe-image-tool',
1790
1705
  onClick: () => setImageFullscreen((value) => !value),
1791
- 'aria-label': imageFullscreen ? 'Exit fullscreen' : 'Enter fullscreen',
1792
- title: imageFullscreen ? 'Exit fullscreen' : 'Fullscreen',
1706
+ 'aria-label': imageFullscreen ? t('files.exitFullscreen') : t('files.enterFullscreen'),
1707
+ title: imageFullscreen ? t('files.exitFullscreen') : t('files.fullscreen'),
1793
1708
  }, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
1794
1709
  )
1795
1710
  : null,
@@ -1797,7 +1712,7 @@ window.__ModuleLoader__.load({
1797
1712
  type: 'button',
1798
1713
  className: 'dsh-fe-icon',
1799
1714
  onClick: closePreview,
1800
- 'aria-label': 'Close preview',
1715
+ 'aria-label': t('files.closePreview'),
1801
1716
  }, svgIcon(closeIcon, 14)),
1802
1717
  ),
1803
1718
  React.createElement('div', {
@@ -1809,7 +1724,7 @@ window.__ModuleLoader__.load({
1809
1724
  onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1810
1725
  },
1811
1726
  previewLoading
1812
- ? 'Loading...'
1727
+ ? t('files.loading')
1813
1728
  : preview.error
1814
1729
  ? preview.error
1815
1730
  : preview.kind === 'image'
@@ -1819,26 +1734,86 @@ window.__ModuleLoader__.load({
1819
1734
  className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
1820
1735
  style: { transform: `translate3d(${imageOffset.x}px, ${imageOffset.y}px, 0) scale(${imageScale})` },
1821
1736
  src: preview.dataUrl,
1822
- alt: preview.name || 'Image preview',
1737
+ alt: preview.name || t('files.imagePreview'),
1823
1738
  draggable: false,
1824
1739
  })
1825
1740
  )
1826
- : preview.tooLarge ? 'Image is too large to preview here' : '(No preview)'
1741
+ : preview.tooLarge ? t('files.imageTooLarge') : t('files.noPreview')
1827
1742
  : preview.text !== undefined && preview.text !== null
1828
1743
  ? preview.text
1829
1744
  : preview.tooLarge
1830
- ? 'File is too large to preview here'
1831
- : preview.binary ? 'Binary file cannot be previewed' : '(No preview)',
1745
+ ? t('files.fileTooLarge')
1746
+ : preview.binary ? t('files.binaryNoPreview') : t('files.noPreview'),
1832
1747
  ),
1833
1748
  ),
1834
1749
  ),
1835
1750
  )
1836
1751
  }
1752
+ }
1753
+
1754
+ function createFileExplorerGitPage(t) {
1755
+ return function FileExplorerGitPage(props) {
1756
+ if (typeof props.useSessions !== 'function') return null
1757
+
1758
+ const sessionId = props.useSessions((state) => {
1759
+ const current = state.current
1760
+ if (current === undefined) return undefined
1761
+ const row = state.byId[current]
1762
+ if (!row || row.blank === true) return undefined
1763
+ return current
1764
+ })
1765
+ const cwd = props.useSessions((state) => {
1766
+ const current = state.current
1767
+ if (current === undefined) return undefined
1768
+ const row = state.byId[current]
1769
+ return row ? row.cwd : undefined
1770
+ })
1771
+
1772
+ if (sessionId === undefined) return null
1773
+ return React.createElement('div', {
1774
+ className: 'dsh-fe-page',
1775
+ role: 'region',
1776
+ 'aria-label': t('git.title'),
1777
+ }, React.createElement(GitGraphView, { api, cwd }))
1778
+ }
1779
+ }
1780
+
1781
+ const GitGraphView = createGitGraphView(t);
1782
+ const FileExplorerPage = createFileExplorerPage(t);
1783
+ const FileExplorerGitPage = createFileExplorerGitPage(t);
1837
1784
 
1838
- ctx.effect(() => slots.inject('details', () => slots.register(
1839
- { name: 'details', priority: -6 },
1840
- FileExplorerPanel,
1841
- )))
1785
+ ctx.effect(() => {
1786
+ const disposeFilesPage = rightPanel.registerPage({
1787
+ id: 'file-explorer.files',
1788
+ title: t('files.title'),
1789
+ group: t('workspace'),
1790
+ order: 20,
1791
+ placement: 'rail',
1792
+ icon: (size) => svgIcon(folderIcon, size),
1793
+ })
1794
+ const disposeGitPage = rightPanel.registerPage({
1795
+ id: 'file-explorer.git',
1796
+ title: t('git.title'),
1797
+ group: t('workspace'),
1798
+ order: 21,
1799
+ placement: 'rail',
1800
+ icon: (size) => svgIcon(branchIcon, size),
1801
+ })
1802
+ const disposeFilesSlot = slots.inject('right-panel.page', () => slots.register({
1803
+ name: 'right-panel.page',
1804
+ key: 'file-explorer.files',
1805
+ }, FileExplorerPage))
1806
+ const disposeGitSlot = slots.inject('right-panel.page', () => slots.register({
1807
+ name: 'right-panel.page',
1808
+ key: 'file-explorer.git',
1809
+ }, FileExplorerGitPage))
1810
+ return () => {
1811
+ disposeFilesSlot()
1812
+ disposeGitSlot()
1813
+ disposeFilesPage()
1814
+ disposeGitPage()
1815
+ }
1816
+ })
1842
1817
  }
1843
1818
 
1844
1819
  exports.apply = apply;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "DSH bundle plugin that replaces the Web GUI details column with a workspace file explorer.",
7
+ "description": "DSH bundle plugin that contributes a workspace file explorer page to the right-panel host.",
8
8
  "type": "module",
9
9
  "main": "index.js",
10
10
  "exports": {
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "client": {
20
20
  "inject": [
21
+ "@yiln-dsh/dsh-plugin-right-panel",
21
22
  "@deepseek-ai/dsh-client-runtime",
22
23
  "@deepseek-ai/dsh-client-ui-layout",
23
24
  "@deepseek-ai/dsh-client-ui-renderer"
@@ -35,7 +36,8 @@
35
36
  "@deepseek-ai/cordis": ">=4.0.0",
36
37
  "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.0",
37
38
  "@deepseek-ai/dsh-client-ui-layout": ">=0.1.0-rc.0",
38
- "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.0-rc.0"
39
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.0-rc.0",
40
+ "@yiln-dsh/dsh-plugin-right-panel": ">=0.1.0"
39
41
  },
40
42
  "engines": {
41
43
  "node": ">=22"