@yiln-dsh/dsh-plugin-file-explorer 0.4.0 → 0.5.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 +26 -34
  2. package/client.js +303 -284
  3. package/index.js +24 -4
  4. package/package.json +5 -3
package/README.md CHANGED
@@ -1,16 +1,17 @@
1
- # dsh-plugin-file-explorer
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
- hover; delete uses a second-click confirm.
13
- - **Git Graph** tab: a vscode/le-git-graph style commit graph rendered from
12
+ hover; delete uses a second-click confirm. Image View supports fullscreen,
13
+ 25%-400% zoom, wheel zoom, and drag-to-pan after zooming.
14
+ - **Git Graph** page: a vscode/le-git-graph style commit graph rendered from
14
15
  the repository containing the current directory —
15
16
  - colored lane graph with commit dots and merge curves (all branches or the
16
17
  current branch, refreshable),
@@ -21,35 +22,26 @@ GUI with a workspace file explorer.
21
22
  A/M/D/R status badges,
22
23
  - click a file to open its diff patch (commit shows `git show`, working tree
23
24
  shows `git diff HEAD`) in a dialog.
24
- - The column is a real layout column: it squeezes the conversation and its
25
- width can be dragged between 300–520px with the native splitter handle.
26
- - The chosen width persists across sessions and page reloads (localStorage):
27
- the plugin captures the shell layout store actions when the root entry wires
28
- them, restores the saved width right after `openDetails()`, and saves the
29
- column width after every resizer drag.
30
- - **Collapse to icon bar**: a collapse button sits left of the header title
31
- (chevrons pointing right, matching the DSH session menu's compact icon
32
- style). Clicking it closes the details column and leaves a slim rail pinned
33
- to the right edge with two icons — **Files** and **Git Graph** — clicking
34
- either reopens the drawer on that tab at the last chosen width. The layout
35
- keeps the slot subtree mounted at width 0, so the rail survives and the
36
- panel state 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 plugin is version `0.3.0` from its own `package.json`.
39
+ The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.5.0`.
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,18 +53,18 @@ pnpm pack
61
53
  ```
62
54
 
63
55
  ```bash
64
- dsh plugin --profile web add ./dsh-plugin-file-explorer-0.2.0.tgz
56
+ dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.5.0.tgz
65
57
  ```
66
58
 
67
59
  ### npm package
68
60
 
69
61
  ```bash
70
62
  cd /path/to/dsh-plugin-file-explorer
71
- npm publish
63
+ npm publish --access public
72
64
  ```
73
65
 
74
66
  ```bash
75
- dsh plugin --profile web add dsh-plugin-file-explorer
67
+ dsh plugin --profile web add @yiln-dsh/dsh-plugin-file-explorer@latest
76
68
  ```
77
69
 
78
70
  The profile must be the `web` profile. The host row serves the API routes and
@@ -83,8 +75,9 @@ apply the new profile composition.
83
75
 
84
76
  - The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
85
77
  and returns `{ ok, path, parent, entries }` JSON — never live objects.
86
- - `read` previews up to 256 KiB of UTF-8 text; `download` returns a base64
87
- data URL (64 MiB cap) that the browser triggers with `<a download>`.
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>`.
88
81
  - `delete` removes regular files (`rm -f`) and directories recursively
89
82
  (`rm -rf`); both are called only after a second-click confirm in the UI.
90
83
  - Git routes are read-only. They resolve the repository root with
@@ -97,7 +90,6 @@ apply the new profile composition.
97
90
  sentinel) and file paths against a non-option, non-control-character check.
98
91
  - Rows have a fixed 34px height, so the hover action swap never changes the
99
92
  row height; directories reveal a delete-only action set on hover.
100
- - The browser module uses the `details` slot (scope: session), registering at
101
- priority `-6` to shadow the built-in tool-details occupant (priority `0`),
102
- the `layout` service (`openDetails` / `closeDetails`), the standard
103
- `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,12 @@ 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"];
24
23
 
25
24
  function apply(ctx) {
26
25
  const slots = ctx.get('slots')
27
- const layout = ctx.get('layout')
28
- if (slots === undefined) return
26
+ const rightPanel = ctx.get('rightPanel')
27
+ if (slots === undefined || rightPanel === undefined) return
29
28
 
30
29
  const style = document.createElement("style");
31
30
  style.id = "dsh-plugin-file-explorer-style";
@@ -77,35 +76,6 @@ window.__ModuleLoader__.load({
77
76
  .dsh-fe-icon:hover {
78
77
  background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2, #e7e5e4));
79
78
  }
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
79
  .dsh-fe-path {
110
80
  min-width: 0;
111
81
  margin: 8px 12px 0;
@@ -298,6 +268,14 @@ window.__ModuleLoader__.load({
298
268
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
299
269
  overflow: hidden;
300
270
  }
271
+ .dsh-fe-modal-fullscreen {
272
+ width: 100vw;
273
+ height: 100vh;
274
+ max-width: none;
275
+ max-height: none;
276
+ border: 0;
277
+ border-radius: 0;
278
+ }
301
279
  .dsh-fe-modal-head {
302
280
  display: flex;
303
281
  align-items: center;
@@ -319,6 +297,29 @@ window.__ModuleLoader__.load({
319
297
  color: var(--dsw-alias-label-secondary, #6b7280);
320
298
  font-size: 12px;
321
299
  }
300
+ .dsh-fe-image-tools {
301
+ flex: 0 0 auto;
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: 2px;
305
+ }
306
+ .dsh-fe-image-tool {
307
+ width: 28px;
308
+ height: 28px;
309
+ padding: 0;
310
+ }
311
+ .dsh-fe-image-tool:disabled {
312
+ opacity: 0.35;
313
+ cursor: default;
314
+ }
315
+ .dsh-fe-image-zoom-label {
316
+ display: inline-block;
317
+ min-width: 40px;
318
+ color: var(--dsw-alias-label-secondary, #6b7280);
319
+ font-size: 11px;
320
+ text-align: center;
321
+ white-space: nowrap;
322
+ }
322
323
  .dsh-fe-modal-body {
323
324
  flex: 1 1 auto;
324
325
  min-height: 0;
@@ -331,6 +332,47 @@ window.__ModuleLoader__.load({
331
332
  font-size: 12px;
332
333
  line-height: 18px;
333
334
  }
335
+ .dsh-fe-modal-body-image {
336
+ display: flex;
337
+ align-items: stretch;
338
+ justify-content: stretch;
339
+ min-height: 160px;
340
+ padding: 0;
341
+ overflow: hidden;
342
+ background: var(--dsw-alias-bg-base, #f5f5f4);
343
+ white-space: normal;
344
+ }
345
+ .dsh-fe-image-stage {
346
+ flex: 1 1 auto;
347
+ min-width: 0;
348
+ min-height: 0;
349
+ display: flex;
350
+ align-items: center;
351
+ justify-content: center;
352
+ overflow: hidden;
353
+ touch-action: none;
354
+ }
355
+ .dsh-fe-preview-image {
356
+ display: block;
357
+ max-width: 100%;
358
+ max-height: 60vh;
359
+ width: auto;
360
+ height: auto;
361
+ object-fit: contain;
362
+ user-select: none;
363
+ -webkit-user-drag: none;
364
+ transition: transform 120ms ease;
365
+ }
366
+ .dsh-fe-modal-fullscreen .dsh-fe-preview-image {
367
+ max-height: calc(100vh - 64px);
368
+ }
369
+ .dsh-fe-preview-image-pannable {
370
+ cursor: grab;
371
+ }
372
+ .dsh-fe-preview-image-dragging {
373
+ cursor: grabbing;
374
+ transition: none;
375
+ }
334
376
  .dsh-git-bar {
335
377
  display: flex;
336
378
  align-items: center;
@@ -515,154 +557,19 @@ window.__ModuleLoader__.load({
515
557
  font-size: 12px;
516
558
  unicode-bidi: plaintext;
517
559
  }
518
- .dsh-fe-rail {
560
+ .dsh-fe-page {
519
561
  height: 100%;
520
- width: 100%;
521
- box-sizing: border-box;
562
+ min-height: 0;
522
563
  display: flex;
523
564
  flex-direction: column;
524
- align-items: center;
525
- gap: 6px;
526
- padding: 10px 6px;
565
+ box-sizing: border-box;
566
+ overflow: hidden;
527
567
  background: var(--dsw-specific-sidebar-fill, var(--dsw-alias-bg-base, #f5f5f4));
528
- border-left: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
529
- }
530
- /* While collapsed, keep a real RAIL_WIDTH column in the layout (like the left
531
- sidebar's 56px rail) instead of a floating overlay. This !important rule
532
- overrides React's inline grid-template-columns on the app frame; the data
533
- attribute is added by applyRail() and removed on expand/unload. */
534
- [data-dsh-fe-rail] {
535
- grid-template-columns: var(--dsh-fe-rail-sidebar, 280px) minmax(0, 1fr) 56px !important;
536
- }
537
- .dsh-fe-rail-button {
538
- width: 36px;
539
- height: 36px;
540
- border: 0;
541
- border-radius: 8px;
542
- background: transparent;
543
- color: var(--dsw-alias-label-secondary, #4b5563);
544
- cursor: pointer;
545
- display: inline-flex;
546
- align-items: center;
547
- justify-content: center;
548
- }
549
- .dsh-fe-rail-button:hover {
550
- background: var(--dsw-alias-bg-layer-2, #e7e5e4);
551
- color: var(--dsw-alias-brand-primary, #4f46e5);
568
+ color: var(--dsw-alias-label-primary, #111827);
552
569
  }
553
570
  `;
554
571
  document.head.append(style);
555
572
 
556
- // --- details-column width persistence ---------------------------------
557
- // The shell's layout store is transient: openDetails() resets the column
558
- // to its 360px contract default on every mount and session switch. The
559
- // bound store actions reach the LayoutController after every client
560
- // module has applied (the root entry's inject hook fires on first
561
- // render and again on re-register), so wrapping attachPanels on the
562
- // prototype captures them reliably. setDetails then restores the last
563
- // user-chosen width, and a document-level pointerup listener persists
564
- // the column width after each resizer drag.
565
- const WIDTH_KEY = "dsh-plugin-file-explorer.detailsWidth";
566
- const WIDTH_MIN = 300; // the layout contract's clamp floor
567
- let panelActions = null;
568
- let savedWidth = 0;
569
- try { savedWidth = Number(window.localStorage.getItem(WIDTH_KEY)) || 0 } catch (_e) { }
570
-
571
- const findDetailsColumn = () => {
572
- // The details column's own class is the reliable anchor: the drag
573
- // handle's previousElementSibling is the sidebar handle or the
574
- // overlay layer, never the column itself.
575
- const byClass = document.querySelector('[class*="detailsCol"]')
576
- if (byClass !== null) return byClass
577
- const handle = document.querySelector('[data-side="details"]')
578
- if (handle !== null && handle.previousElementSibling !== null) return handle.previousElementSibling
579
- return null
580
- }
581
- const persistWidth = () => {
582
- const column = findDetailsColumn()
583
- if (column === null) return
584
- const width = Math.round(column.getBoundingClientRect().width)
585
- if (width < WIDTH_MIN) return
586
- savedWidth = width
587
- try { window.localStorage.setItem(WIDTH_KEY, String(width)) } catch (_e) { }
588
- }
589
- const onDocumentPointerUp = () => { persistWidth() }
590
- document.addEventListener("pointerup", onDocumentPointerUp, true)
591
- ctx.effect(() => () => document.removeEventListener("pointerup", onDocumentPointerUp, true))
592
-
593
- if (layout !== undefined) {
594
- const proto = Object.getPrototypeOf(layout)
595
- if (proto !== null && typeof proto.attachPanels === "function") {
596
- const originalAttach = proto.attachPanels
597
- proto.attachPanels = function (actions) {
598
- panelActions = actions
599
- return originalAttach.call(this, actions)
600
- }
601
- ctx.effect(() => () => { proto.attachPanels = originalAttach })
602
- }
603
- }
604
- const restoreWidth = () => {
605
- if (savedWidth < WIDTH_MIN) return
606
- if (panelActions === null || typeof panelActions.setDetails !== "function") return
607
- try { panelActions.setDetails(savedWidth) } catch (_e) { }
608
- }
609
-
610
- // --- collapsed rail as a real layout column --------------------------
611
- // The layout contract gives the sidebar a 56px rail when closed but
612
- // closes the details column to 0px (no rail). To collapse like the left
613
- // sidebar, keep the store closed (no drag handle / border) and force the
614
- // frame's third grid track to RAIL_WIDTH via an !important stylesheet
615
- // rule keyed on a data attribute we own. CSS !important beats React's
616
- // inline grid style, so there is no fight with React re-renders: set the
617
- // attribute to collapse, remove it to expand. A MutationObserver only
618
- // refreshes the sidebar-width variable (the first track) when the app
619
- // re-renders the frame.
620
- const RAIL_WIDTH = 56;
621
- let railActive = false;
622
- const frameOfDetails = () => {
623
- const column = findDetailsColumn();
624
- if (column === null || column.parentElement === null) return null;
625
- return column.parentElement;
626
- };
627
- const syncRailVar = () => {
628
- const frame = frameOfDetails();
629
- if (frame === null) return;
630
- const sideCol = frame.children.length > 0 ? frame.children[0] : null;
631
- const sidebarPx = sideCol !== null ? Math.round(sideCol.getBoundingClientRect().width) : 280;
632
- try { frame.style.setProperty("--dsh-fe-rail-sidebar", `${sidebarPx}px`) } catch (_e) { }
633
- };
634
- const applyRail = () => {
635
- const frame = frameOfDetails();
636
- if (frame === null) return;
637
- if (railActive) {
638
- frame.setAttribute("data-dsh-fe-rail", "");
639
- syncRailVar();
640
- } else {
641
- frame.removeAttribute("data-dsh-fe-rail");
642
- }
643
- };
644
- const releaseRail = () => {
645
- railActive = false;
646
- applyRail();
647
- };
648
- let railObserver = null;
649
- if (typeof MutationObserver === "function") {
650
- railObserver = new MutationObserver(() => {
651
- if (railActive) syncRailVar();
652
- });
653
- ctx.effect(() => {
654
- const frame = frameOfDetails();
655
- if (frame !== null) railObserver.observe(frame, { attributes: true, attributeFilter: ["style"] });
656
- return () => {
657
- railObserver?.disconnect();
658
- const frame = frameOfDetails();
659
- if (frame !== null) frame.removeAttribute("data-dsh-fe-rail");
660
- railActive = false;
661
- };
662
- });
663
- }
664
- // -----------------------------------------------------------------------
665
-
666
573
  async function api(method, payload) {
667
574
  const options = { headers: { "content-type": "application/json" } };
668
575
  options.method = "POST";
@@ -726,6 +633,29 @@ window.__ModuleLoader__.load({
726
633
  React.createElement('line', { x1: '18', y1: '6', x2: '6', y2: '18' }),
727
634
  React.createElement('line', { x1: '6', y1: '6', x2: '18', y2: '18' }),
728
635
  )
636
+ const zoomOutIcon = React.createElement(React.Fragment, null,
637
+ React.createElement('circle', { cx: '10.5', cy: '10.5', r: '6.5' }),
638
+ React.createElement('line', { x1: '15.5', y1: '15.5', x2: '21', y2: '21' }),
639
+ React.createElement('line', { x1: '8', y1: '10.5', x2: '13', y2: '10.5' }),
640
+ )
641
+ const zoomInIcon = React.createElement(React.Fragment, null,
642
+ React.createElement('circle', { cx: '10.5', cy: '10.5', r: '6.5' }),
643
+ React.createElement('line', { x1: '15.5', y1: '15.5', x2: '21', y2: '21' }),
644
+ React.createElement('line', { x1: '8', y1: '10.5', x2: '13', y2: '10.5' }),
645
+ React.createElement('line', { x1: '10.5', y1: '8', x2: '10.5', y2: '13' }),
646
+ )
647
+ const fullscreenIcon = React.createElement(React.Fragment, null,
648
+ React.createElement('polyline', { points: '8 3 3 3 3 8' }),
649
+ React.createElement('polyline', { points: '16 3 21 3 21 8' }),
650
+ React.createElement('polyline', { points: '8 21 3 21 3 16' }),
651
+ React.createElement('polyline', { points: '16 21 21 21 21 16' }),
652
+ )
653
+ const fullscreenExitIcon = React.createElement(React.Fragment, null,
654
+ React.createElement('polyline', { points: '9 3 9 9 3 9' }),
655
+ React.createElement('polyline', { points: '15 3 15 9 21 9' }),
656
+ React.createElement('polyline', { points: '9 21 9 15 3 15' }),
657
+ React.createElement('polyline', { points: '15 21 15 15 21 15' }),
658
+ )
729
659
  // Mirror of DeepSeek's sidebar IconPanelLeftOutline16 (spine on the
730
660
  // right): this panel lives on the right edge, so the glyph mirrors the
731
661
  // left sidebar's, signaling collapse into the right-edge icon rail.
@@ -1236,7 +1166,7 @@ window.__ModuleLoader__.load({
1236
1166
  )
1237
1167
  }
1238
1168
 
1239
- function FileExplorerPanel(props) {
1169
+ function FileExplorerPage(props) {
1240
1170
  if (typeof props.useSessions !== 'function') return null
1241
1171
 
1242
1172
  const sessionId = props.useSessions((state) => {
@@ -1263,24 +1193,17 @@ window.__ModuleLoader__.load({
1263
1193
  const [draftPath, setDraftPath] = React.useState('')
1264
1194
  const [preview, setPreview] = React.useState(null)
1265
1195
  const [previewLoading, setPreviewLoading] = React.useState(false)
1196
+ const [imageScale, setImageScale] = React.useState(1)
1197
+ const [imageOffset, setImageOffset] = React.useState({ x: 0, y: 0 })
1198
+ const [imageFullscreen, setImageFullscreen] = React.useState(false)
1199
+ const [imageDragging, setImageDragging] = React.useState(false)
1200
+ const imageDrag = React.useRef(null)
1266
1201
  const [pendingDelete, setPendingDelete] = React.useState(null)
1267
- const [view, setView] = React.useState('files')
1268
- const [collapsed, setCollapsed] = React.useState(false)
1269
-
1270
- React.useEffect(() => {
1271
- if (sessionId === undefined) return
1272
- setCollapsed(false)
1273
- if (layout !== undefined) {
1274
- try { layout.openDetails() } catch (_e) { }
1275
- }
1276
- // Same-tick store updates batch in React, so restoring right after
1277
- // openDetails() replaces the 360px default without a visible jump.
1278
- restoreWidth()
1279
- }, [sessionId])
1280
1202
 
1281
1203
  React.useEffect(() => {
1282
1204
  if (sessionId === undefined) {
1283
- releaseRail()
1205
+ setParentPath(null)
1206
+ setRequestPath('')
1284
1207
  return
1285
1208
  }
1286
1209
  setParentPath(null)
@@ -1332,34 +1255,6 @@ window.__ModuleLoader__.load({
1332
1255
  return aDir - bDir || a.name.localeCompare(b.name)
1333
1256
  })
1334
1257
  const isRoot = parentPath === null || parentPath === currentPath
1335
- // Collapse into a slim icon rail that keeps a real 56px column in
1336
- // the layout (like the left sidebar's rail): the details store stays
1337
- // closed (no drag handle / border), and applyRail() toggles the
1338
- // [data-dsh-fe-rail] attribute so the !important stylesheet rule
1339
- // overrides the frame's third grid track. A MutationObserver keeps
1340
- // the sidebar-width variable fresh across app re-renders.
1341
- const collapse = () => {
1342
- setCollapsed(true)
1343
- railActive = true
1344
- setPreview(null)
1345
- setPendingDelete(null)
1346
- setEditingPath(false)
1347
- if (layout !== undefined) {
1348
- try { layout.closeDetails() } catch (_e) { }
1349
- }
1350
- applyRail()
1351
- }
1352
- const expand = (nextView) => {
1353
- releaseRail()
1354
- setCollapsed(false)
1355
- setView(nextView)
1356
- if (layout !== undefined) {
1357
- try { layout.openDetails() } catch (_e) { }
1358
- }
1359
- // Same-tick store updates batch in React, so restoring right after
1360
- // openDetails() replaces the 360px default without a visible jump.
1361
- restoreWidth()
1362
- }
1363
1258
  const reload = () => { setPendingDelete(null); setReloadTick((tick) => tick + 1) }
1364
1259
  const startEditPath = () => {
1365
1260
  setDraftPath(currentPath)
@@ -1373,8 +1268,67 @@ window.__ModuleLoader__.load({
1373
1268
  setRequestPath(next)
1374
1269
  }
1375
1270
 
1271
+ const resetImageZoom = () => {
1272
+ imageDrag.current = null
1273
+ setImageDragging(false)
1274
+ setImageScale(1)
1275
+ setImageOffset({ x: 0, y: 0 })
1276
+ }
1277
+ const resetImageView = () => {
1278
+ resetImageZoom()
1279
+ setImageFullscreen(false)
1280
+ }
1281
+ const closePreview = () => {
1282
+ resetImageView()
1283
+ setPreview(null)
1284
+ }
1285
+ const setImageZoom = (value) => {
1286
+ const next = Math.max(0.25, Math.min(4, Math.round(value * 4) / 4))
1287
+ setImageScale(next)
1288
+ if (next <= 1) setImageOffset({ x: 0, y: 0 })
1289
+ }
1290
+ const changeImageZoom = (delta) => setImageZoom(imageScale + delta)
1291
+ const handleImageWheel = (event) => {
1292
+ event.preventDefault()
1293
+ changeImageZoom(event.deltaY < 0 ? 0.25 : -0.25)
1294
+ }
1295
+ const startImageDrag = (event) => {
1296
+ if (imageScale <= 1 || event.button !== 0) return
1297
+ imageDrag.current = {
1298
+ pointerId: event.pointerId,
1299
+ startX: event.clientX,
1300
+ startY: event.clientY,
1301
+ originX: imageOffset.x,
1302
+ originY: imageOffset.y,
1303
+ }
1304
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch (_e) { }
1305
+ setImageDragging(true)
1306
+ }
1307
+ const moveImageDrag = (event) => {
1308
+ const drag = imageDrag.current
1309
+ if (!drag || drag.pointerId !== event.pointerId) return
1310
+ setImageOffset({
1311
+ x: drag.originX + event.clientX - drag.startX,
1312
+ y: drag.originY + event.clientY - drag.startY,
1313
+ })
1314
+ }
1315
+ const endImageDrag = (event) => {
1316
+ const drag = imageDrag.current
1317
+ if (!drag || drag.pointerId !== event.pointerId) return
1318
+ imageDrag.current = null
1319
+ setImageDragging(false)
1320
+ try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (_e) { }
1321
+ }
1322
+ const handlePreviewKeyDown = (event) => {
1323
+ if (event.key !== 'Escape') return
1324
+ event.stopPropagation()
1325
+ if (imageFullscreen) setImageFullscreen(false)
1326
+ else closePreview()
1327
+ }
1328
+
1376
1329
  const openPreview = (entry) => {
1377
1330
  setPendingDelete(null)
1331
+ resetImageView()
1378
1332
  setPreviewLoading(true)
1379
1333
  setPreview(null)
1380
1334
  api('read', { path: entry.path })
@@ -1519,103 +1473,168 @@ window.__ModuleLoader__.load({
1519
1473
  ),
1520
1474
  )
1521
1475
 
1522
- // Collapsed: a slim icon rail (file / git) that keeps a real 56px
1523
- // column in the layout (the [data-dsh-fe-rail] !important grid rule;
1524
- // see applyRail above), mirroring the left sidebar's collapsed rail.
1525
- // Placed after every handler declaration: a collapsed render must not
1526
- // return early and leave `expand` uninitialized (TDZ error on click).
1527
- if (collapsed) {
1528
- return React.createElement('div', {
1529
- className: 'dsh-fe-rail',
1530
- role: 'region',
1531
- 'aria-label': 'File explorer (collapsed)',
1532
- },
1533
- React.createElement('button', {
1534
- type: 'button',
1535
- className: 'dsh-fe-rail-button',
1536
- onClick: () => expand('files'),
1537
- title: 'Files',
1538
- 'aria-label': 'Open file explorer',
1539
- }, svgIcon(folderIcon, 16)),
1540
- React.createElement('button', {
1541
- type: 'button',
1542
- className: 'dsh-fe-rail-button',
1543
- onClick: () => expand('git'),
1544
- title: 'Git Graph',
1545
- 'aria-label': 'Open git graph',
1546
- }, svgIcon(branchIcon, 16)),
1547
- )
1548
- }
1549
-
1550
1476
  return React.createElement(React.Fragment, null,
1551
1477
  React.createElement('div', {
1552
- className: 'dsh-fe-panel',
1478
+ className: 'dsh-fe-page',
1553
1479
  role: 'region',
1554
- 'aria-label': 'File explorer',
1480
+ 'aria-label': 'Files',
1555
1481
  },
1556
- React.createElement('div', { className: 'dsh-fe-header' },
1557
- React.createElement('button', {
1558
- type: 'button',
1559
- className: 'dsh-fe-icon',
1560
- onClick: collapse,
1561
- 'aria-label': 'Collapse file explorer into icon bar',
1562
- title: 'Collapse',
1563
- }, collapseIcon),
1564
- React.createElement('div', { className: 'dsh-fe-title' }, view === 'git' ? 'Git Graph' : 'Files'),
1565
- ),
1566
- React.createElement('div', { className: 'dsh-fe-tabs' },
1567
- React.createElement('button', {
1568
- type: 'button',
1569
- className: 'dsh-fe-tab' + (view === 'files' ? ' dsh-fe-tab-active' : ''),
1570
- onClick: () => setView('files'),
1571
- }, svgIcon(folderIcon, 13), 'Files'),
1572
- React.createElement('button', {
1573
- type: 'button',
1574
- className: 'dsh-fe-tab' + (view === 'git' ? ' dsh-fe-tab-active' : ''),
1575
- onClick: () => setView('git'),
1576
- }, svgIcon(branchIcon, 13), 'Git Graph'),
1577
- ),
1578
- view === 'git'
1579
- ? React.createElement(GitGraphView, { api, cwd })
1580
- : filesView,
1482
+ filesView,
1581
1483
  ),
1582
1484
  preview &&
1583
1485
  React.createElement('div', {
1584
1486
  className: 'dsh-fe-overlay',
1585
- onClick: () => setPreview(null),
1487
+ onClick: closePreview,
1586
1488
  },
1587
1489
  React.createElement('div', {
1588
- className: 'dsh-fe-modal',
1490
+ className: preview.kind === 'image' && imageFullscreen ? 'dsh-fe-modal dsh-fe-modal-fullscreen' : 'dsh-fe-modal',
1589
1491
  role: 'dialog',
1590
1492
  'aria-label': 'File preview',
1493
+ 'aria-modal': true,
1494
+ tabIndex: -1,
1495
+ onKeyDown: handlePreviewKeyDown,
1591
1496
  onClick: (event) => event.stopPropagation(),
1592
1497
  },
1593
1498
  React.createElement('div', { className: 'dsh-fe-modal-head' },
1594
1499
  React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || 'Preview'),
1595
- React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.binary ? 'Binary' : formatSize(preview.size)),
1500
+ React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? `Image / ${formatSize(preview.size)}` : preview.binary ? 'Binary' : formatSize(preview.size)),
1501
+ preview.kind === 'image' && preview.dataUrl
1502
+ ? React.createElement('div', {
1503
+ className: 'dsh-fe-image-tools',
1504
+ role: 'toolbar',
1505
+ 'aria-label': 'Image controls',
1506
+ },
1507
+ React.createElement('button', {
1508
+ type: 'button',
1509
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1510
+ disabled: imageScale <= 0.25,
1511
+ onClick: () => changeImageZoom(-0.25),
1512
+ 'aria-label': 'Zoom out',
1513
+ title: 'Zoom out',
1514
+ }, svgIcon(zoomOutIcon, 14)),
1515
+ React.createElement('button', {
1516
+ type: 'button',
1517
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1518
+ onClick: resetImageZoom,
1519
+ 'aria-label': 'Reset image zoom',
1520
+ title: 'Reset zoom',
1521
+ }, React.createElement('span', { className: 'dsh-fe-image-zoom-label' }, `${Math.round(imageScale * 100)}%`)),
1522
+ React.createElement('button', {
1523
+ type: 'button',
1524
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1525
+ disabled: imageScale >= 4,
1526
+ onClick: () => changeImageZoom(0.25),
1527
+ 'aria-label': 'Zoom in',
1528
+ title: 'Zoom in',
1529
+ }, svgIcon(zoomInIcon, 14)),
1530
+ React.createElement('button', {
1531
+ type: 'button',
1532
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1533
+ onClick: () => setImageFullscreen((value) => !value),
1534
+ 'aria-label': imageFullscreen ? 'Exit fullscreen' : 'Enter fullscreen',
1535
+ title: imageFullscreen ? 'Exit fullscreen' : 'Fullscreen',
1536
+ }, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
1537
+ )
1538
+ : null,
1596
1539
  React.createElement('button', {
1597
1540
  type: 'button',
1598
1541
  className: 'dsh-fe-icon',
1599
- onClick: () => setPreview(null),
1542
+ onClick: closePreview,
1600
1543
  'aria-label': 'Close preview',
1601
1544
  }, svgIcon(closeIcon, 14)),
1602
1545
  ),
1603
- React.createElement('div', { className: 'dsh-fe-modal-body' },
1546
+ React.createElement('div', {
1547
+ className: preview.kind === 'image' ? 'dsh-fe-modal-body dsh-fe-modal-body-image' : 'dsh-fe-modal-body',
1548
+ onWheel: preview.kind === 'image' && preview.dataUrl ? handleImageWheel : undefined,
1549
+ onPointerDown: preview.kind === 'image' && preview.dataUrl ? startImageDrag : undefined,
1550
+ onPointerMove: preview.kind === 'image' && preview.dataUrl ? moveImageDrag : undefined,
1551
+ onPointerUp: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1552
+ onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1553
+ },
1604
1554
  previewLoading
1605
1555
  ? 'Loading...'
1606
1556
  : preview.error
1607
1557
  ? preview.error
1608
- : preview.text !== undefined ? preview.text : preview.tooLarge ? 'File is too large to preview here' : '(No preview)',
1558
+ : preview.kind === 'image'
1559
+ ? preview.dataUrl
1560
+ ? React.createElement('div', { className: 'dsh-fe-image-stage' },
1561
+ React.createElement('img', {
1562
+ className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
1563
+ style: { transform: `translate3d(${imageOffset.x}px, ${imageOffset.y}px, 0) scale(${imageScale})` },
1564
+ src: preview.dataUrl,
1565
+ alt: preview.name || 'Image preview',
1566
+ draggable: false,
1567
+ })
1568
+ )
1569
+ : preview.tooLarge ? 'Image is too large to preview here' : '(No preview)'
1570
+ : preview.text !== undefined && preview.text !== null
1571
+ ? preview.text
1572
+ : preview.tooLarge
1573
+ ? 'File is too large to preview here'
1574
+ : preview.binary ? 'Binary file cannot be previewed' : '(No preview)',
1609
1575
  ),
1610
1576
  ),
1611
1577
  ),
1612
1578
  )
1613
1579
  }
1614
1580
 
1615
- ctx.effect(() => slots.inject('details', () => slots.register(
1616
- { name: 'details', priority: -6 },
1617
- FileExplorerPanel,
1618
- )))
1581
+ function FileExplorerGitPage(props) {
1582
+ if (typeof props.useSessions !== 'function') return null
1583
+
1584
+ const sessionId = props.useSessions((state) => {
1585
+ const current = state.current
1586
+ if (current === undefined) return undefined
1587
+ const row = state.byId[current]
1588
+ if (!row || row.blank === true) return undefined
1589
+ return current
1590
+ })
1591
+ const cwd = props.useSessions((state) => {
1592
+ const current = state.current
1593
+ if (current === undefined) return undefined
1594
+ const row = state.byId[current]
1595
+ return row ? row.cwd : undefined
1596
+ })
1597
+
1598
+ if (sessionId === undefined) return null
1599
+ return React.createElement('div', {
1600
+ className: 'dsh-fe-page',
1601
+ role: 'region',
1602
+ 'aria-label': 'Git Graph',
1603
+ }, React.createElement(GitGraphView, { api, cwd }))
1604
+ }
1605
+
1606
+ ctx.effect(() => {
1607
+ const disposeFilesPage = rightPanel.registerPage({
1608
+ id: 'file-explorer.files',
1609
+ title: 'Files',
1610
+ group: 'Workspace',
1611
+ order: 20,
1612
+ placement: 'rail',
1613
+ icon: (size) => svgIcon(folderIcon, size),
1614
+ })
1615
+ const disposeGitPage = rightPanel.registerPage({
1616
+ id: 'file-explorer.git',
1617
+ title: 'Git Graph',
1618
+ group: 'Workspace',
1619
+ order: 21,
1620
+ placement: 'rail',
1621
+ icon: (size) => svgIcon(branchIcon, size),
1622
+ })
1623
+ const disposeFilesSlot = slots.inject('right-panel.page', () => slots.register({
1624
+ name: 'right-panel.page',
1625
+ key: 'file-explorer.files',
1626
+ }, FileExplorerPage))
1627
+ const disposeGitSlot = slots.inject('right-panel.page', () => slots.register({
1628
+ name: 'right-panel.page',
1629
+ key: 'file-explorer.git',
1630
+ }, FileExplorerGitPage))
1631
+ return () => {
1632
+ disposeFilesSlot()
1633
+ disposeGitSlot()
1634
+ disposeFilesPage()
1635
+ disposeGitPage()
1636
+ }
1637
+ })
1619
1638
  }
1620
1639
 
1621
1640
  exports.apply = apply;
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * dsh-plugin-file-explorer — Host half (static DSH bundle).
3
3
  *
4
4
  * Registers exact HTTP routes under /_dsh/file-explorer for the browser
5
- * bundle: list (with parent path), read (text preview), download (data URL),
5
+ * bundle: list (with parent path), read (text/image preview), download (data URL),
6
6
  * and delete.
7
7
  */
8
8
 
@@ -145,12 +145,32 @@ export function apply(ctx) {
145
145
  const target = await fs.resolve(req.path)
146
146
  const info = await fs.stat(target)
147
147
  const size = info && typeof info.size === 'number' ? info.size : null
148
- const limit = 256 * 1024
148
+ const name = target.displayPath.split('/').pop()
149
+ const mime = guessMime(name)
150
+ const isImage = mime.startsWith('image/')
151
+ const limit = isImage ? 16 * 1024 * 1024 : 1024 * 1024
149
152
  if (size !== null && size > limit) {
150
- sendJson(res, 200, { ok: true, name: target.displayPath.split('/').pop(), size, tooLarge: true })
153
+ sendJson(res, 200, {
154
+ ok: true,
155
+ name,
156
+ size,
157
+ tooLarge: true,
158
+ ...(isImage ? { kind: 'image', mime } : {}),
159
+ })
151
160
  return
152
161
  }
153
162
  const bytes = await fs.readBytes(target, undefined, limit)
163
+ if (isImage) {
164
+ sendJson(res, 200, {
165
+ ok: true,
166
+ name,
167
+ size,
168
+ kind: 'image',
169
+ mime,
170
+ dataUrl: `data:${mime};base64,${bytesToBase64(bytes)}`,
171
+ })
172
+ return
173
+ }
154
174
  const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
155
175
  let binary = false
156
176
  for (let i = 0; i < bytes.length; i += 1) {
@@ -159,7 +179,7 @@ export function apply(ctx) {
159
179
  if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
160
180
  sendJson(res, 200, {
161
181
  ok: true,
162
- name: target.displayPath.split('/').pop(),
182
+ name,
163
183
  size,
164
184
  text: binary ? null : text,
165
185
  binary,
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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"