@ssebastien/tcmd 0.1.4 → 0.1.7

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 (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. package/tinyjs.json +16 -0
@@ -0,0 +1,31 @@
1
+ // Project: TinyCommand
2
+ // License: BSD
3
+ // Build the self-contained webview bundle. tinyjs invokes this hook on
4
+ // `tinyjs build`; `desktop:dev` also runs it before opening the native window.
5
+
6
+ import { copyFile, mkdir } from "node:fs/promises"
7
+ import { fileURLToPath } from "node:url"
8
+
9
+ const root = new URL("../", import.meta.url)
10
+ const output = new URL("../desktop/dist/", import.meta.url)
11
+ await mkdir(output, { recursive: true })
12
+
13
+ const result = await Bun.build({
14
+ entrypoints: [fileURLToPath(new URL("../src/js/tcmd/desktop/main.js", import.meta.url))],
15
+ outdir: fileURLToPath(output),
16
+ target: "browser",
17
+ naming: "main.js",
18
+ })
19
+ if (!result.success) throw new AggregateError(result.logs, "Could not build the TinyCommand desktop frontend")
20
+
21
+ await copyFile(new URL("../desktop/frontend/index.html", import.meta.url), new URL("index.html", output))
22
+ await copyFile(new URL("../public/style.css", import.meta.url), new URL("style.css", output))
23
+ await mkdir(new URL("../desktop/dist/fonts/", import.meta.url), { recursive: true })
24
+ await copyFile(
25
+ new URL("../public/fonts/M+1CodeNerdFontMono-Regular.ttf", import.meta.url),
26
+ new URL("../desktop/dist/fonts/M+1CodeNerdFontMono-Regular.ttf", import.meta.url),
27
+ )
28
+ await copyFile(new URL("../public/fonts/OFL.txt", import.meta.url), new URL("../desktop/dist/fonts/OFL.txt", import.meta.url))
29
+ await mkdir(new URL("../desktop/dist/licenses/", import.meta.url), { recursive: true })
30
+ await copyFile(new URL("../public/licenses/LUCIDE.txt", import.meta.url), new URL("../desktop/dist/licenses/LUCIDE.txt", import.meta.url))
31
+ console.log(`TinyCommand desktop frontend built in ${fileURLToPath(output)}`)
@@ -1,5 +1,57 @@
1
- <div class="app" ref="root" tabindex="0" on:keydown="keys">
2
- <div out="headerView|Header"></div>
1
+ <div class="app" ref="root" tabindex="0" out:class="appModeClass" on:keydown="keys">
2
+ <svg class="lucide-defs" aria-hidden="true" focusable="false">
3
+ <defs>
4
+ <symbol id="lucide-folder-plus" viewBox="0 0 24 24"><path d="M12 10v6"/><path d="M9 13h6"/><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
5
+ <symbol id="lucide-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
6
+ <symbol id="lucide-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
7
+ <symbol id="lucide-arrow-up" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></symbol>
8
+ <symbol id="lucide-refresh-cw" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
9
+ <symbol id="lucide-columns-2" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/></symbol>
10
+ <symbol id="lucide-panel-right" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></symbol>
11
+ <symbol id="lucide-terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
12
+ <symbol id="lucide-circle-question-mark" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></symbol>
13
+ <symbol id="lucide-chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
14
+ <symbol id="lucide-arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></symbol>
15
+ <symbol id="lucide-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
16
+ <symbol id="lucide-bookmark" viewBox="0 0 24 24"><path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"/></symbol>
17
+ <symbol id="lucide-maximize" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></symbol>
18
+ <symbol id="lucide-trash" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
19
+ <symbol id="lucide-power" viewBox="0 0 24 24"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></symbol>
20
+ <symbol id="lucide-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></symbol>
21
+ <symbol id="lucide-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
22
+ <symbol id="lucide-link-2" viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/></symbol>
23
+ <symbol id="lucide-cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
24
+ <symbol id="lucide-file" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></symbol>
25
+ <symbol id="lucide-file-code" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m10 13-2 2 2 2"/><path d="m14 13 2 2-2 2"/></symbol>
26
+ <symbol id="lucide-file-text" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol>
27
+ <symbol id="lucide-file-image" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="12" r="2"/><path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/></symbol>
28
+ <symbol id="lucide-audio-lines" viewBox="0 0 24 24"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></symbol>
29
+ <symbol id="lucide-video" viewBox="0 0 24 24"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/></symbol>
30
+ <symbol id="lucide-file-archive" viewBox="0 0 24 24"><path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/><circle cx="8" cy="20" r="2"/></symbol>
31
+ <symbol id="lucide-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
32
+ <symbol id="lucide-settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></symbol>
33
+ </defs>
34
+ </svg>
35
+ <div when="!desktopMode" out="headerView|Header"></div>
36
+ <div class="desktop-toolbar" when="desktopMode" role="toolbar" aria-label="File browser">
37
+ <div class="desktop-toolbar-group">
38
+ <button class="desktop-toolbar-button is-primary" type="button" title="Open a folder" aria-label="Open a folder" on:click="!toolbar:open-folder"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-folder-plus"></use></svg><span>Open folder</span></button>
39
+ <button class="desktop-toolbar-button" type="button" title="Go back" aria-label="Go back" on:click="!toolbar:back"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-arrow-left"></use></svg><span>Back</span></button>
40
+ <button class="desktop-toolbar-button" type="button" title="Go forward" aria-label="Go forward" on:click="!toolbar:forward"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-arrow-right"></use></svg><span>Forward</span></button>
41
+ <button class="desktop-toolbar-button" type="button" title="Go to the parent folder" aria-label="Go to the parent folder" on:click="!toolbar:up"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-arrow-up"></use></svg><span>Up</span></button>
42
+ <button class="desktop-toolbar-button" type="button" title="Refresh the active folder" aria-label="Refresh the active folder" on:click="!toolbar:refresh"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-refresh-cw"></use></svg><span>Refresh</span></button>
43
+ </div>
44
+ <span class="desktop-toolbar-separator" aria-hidden="true"></span>
45
+ <div class="desktop-toolbar-group">
46
+ <button class="desktop-toolbar-button" type="button" title="Switch between Explorer and Commander" aria-label="Switch view mode" on:click="!toolbar:toggle-mode"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-columns-2"></use></svg><span out="desktopViewLabel"></span></button>
47
+ <button class="desktop-toolbar-button" type="button" title="Toggle the preview pane" aria-label="Toggle the preview pane" on:click="!toolbar:toggle-preview"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-panel-right"></use></svg><span out="desktopPreviewLabel"></span></button>
48
+ </div>
49
+ <span class="desktop-toolbar-spacer"></span>
50
+ <div class="desktop-toolbar-group">
51
+ <button class="desktop-toolbar-button" type="button" title="Open the command panel" aria-label="Open the command panel" on:click="!toolbar:commands"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-terminal"></use></svg><span>Commands</span><svg class="toolbar-chevron ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-chevron-right"></use></svg></button>
52
+ <button class="desktop-toolbar-button" type="button" title="Show keyboard shortcuts" aria-label="Show keyboard shortcuts" on:click="!toolbar:help"><svg class="toolbar-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-circle-question-mark"></use></svg><span>Help</span></button>
53
+ </div>
54
+ </div>
3
55
  <section class="resize-hint" when="tooSmall">
4
56
  <h2 out="minSizeTitle"></h2>
5
57
  <p out="minSizeHint"></p>
@@ -12,7 +64,6 @@
12
64
  </div>
13
65
  <div data-slot="list" out="parentListView|FileList"></div>
14
66
  <div out="parentDetailView|EntryDetail"></div>
15
- <div out="parentSummaryView|Summary"></div>
16
67
  </section>
17
68
  <section class="pane pane-left" when="showLeftPane" out:class="leftPaneClass" on:click="leftId!pane:activate">
18
69
  <div class="pane-header">
@@ -21,7 +72,6 @@
21
72
  </div>
22
73
  <div data-slot="list" out="leftListView|FileList"></div>
23
74
  <div out="leftDetailView|EntryDetail"></div>
24
- <div out="leftSummaryView|Summary"></div>
25
75
  </section>
26
76
  <section class="pane pane-right" when="showRightPane" out:class="rightPaneClass" on:click="rightId!pane:activate">
27
77
  <div class="pane-header">
@@ -30,15 +80,15 @@
30
80
  </div>
31
81
  <div data-slot="list" out="rightListView|FileList"></div>
32
82
  <div out="rightDetailView|EntryDetail"></div>
33
- <div out="rightSummaryView|Summary"></div>
34
83
  </section>
35
84
  <div data-slot="inspector" when="showInspectorInline" out="previewView|FilePreview"></div>
36
85
  <div class="preview-max" when="showPreviewFull" out="previewView|FilePreview"></div>
37
86
  </main>
38
87
  <div out="commandBarView|CommandBar"></div>
39
- <div class="viewer" when="showViewer" role="dialog" aria-modal="true" aria-label="File inspector" tabindex="-1" out="previewView|FilePreview"></div>
88
+ <div class="viewer" when="showViewer" role="dialog" aria-modal="true" aria-label="File inspector" tabindex="-1" on:click="!dialog:viewer-cancel" out="previewView|FilePreview"></div>
40
89
  <div when="panelOpen" out="panelView|CommandPanel"></div>
41
90
  <div when="bookmarkOpen" out="bookmarkView|BookmarkList"></div>
91
+ <div when="overlayOpen" out="overlayView|OverlayList"></div>
42
92
  <div out="confirmView|Confirm"></div>
43
93
  <div when="helpOpen" out="helpView|Help"></div>
44
94
  </div>
@@ -4,11 +4,13 @@
4
4
  // Created: 2026-09-26
5
5
 
6
6
  // Module: tcmd/components/App
7
- // Root component: a header, two panes (breadcrumbs + FileList + summary each),
7
+ // Root component: a header, two panes (breadcrumbs + FileList + detail each),
8
8
  // an optional preview column, the command bar and the command panel overlay.
9
9
  // App is the controller seam: component events (published bubbles) and key
10
10
  // chords are translated into `session.dispatch(commandId, payload)` calls; it
11
- // never mutates model state directly.
11
+ // never mutates model state directly. It also owns the shared modal key
12
+ // routing — the fullscreen viewer overlay included — so both renderers behave
13
+ // alike.
12
14
  //
13
15
  // Environment: `normalizeKey(event)` (optional; identity when omitted) and
14
16
  // `quit(self)`. Renderer-specific bindings (pane borders, status color) live in
@@ -17,6 +19,12 @@
17
19
  import { derived, unwrap } from "select/state/cells"
18
20
  import { DEFAULT_KEYMAP, matchKeymap } from "../core/keymap.js"
19
21
  import { PANEL_FOCUS } from "../core/panel.js"
22
+ import { isNarrow } from "./Frame.js"
23
+
24
+ // Constant: DOUBLE_CLICK_MS
25
+ // Two clicks on the same listing row within this window count as a double
26
+ // click, which opens the row like Enter (see `createEvents#list:select`).
27
+ const DOUBLE_CLICK_MS = 400
20
28
 
21
29
  // Function: createInit
22
30
  // State initializer binding the root component to a `session`.
@@ -40,10 +48,8 @@ function createInit(session) {
40
48
  appPath: session.activePath,
41
49
  bookmarkIndex: session.bookmarkIndex,
42
50
  bookmarkItems: session.bookmarkItems,
43
- bookmarkMode: session.bookmarkMode,
44
51
  bookmarkOpen: session.bookmarkOpen,
45
52
  bookmarkQuery: session.bookmarkQuery,
46
- bookmarkTarget: session.bookmarkTarget,
47
53
  commandBarItems: session.commandBarItems,
48
54
  confirm: session.confirm,
49
55
  currentId: session.currentId,
@@ -62,7 +68,10 @@ function createInit(session) {
62
68
  leftSegments: left.segments,
63
69
  leftSetFilter: (value) => left.setFilter(value),
64
70
  notice: session.notice,
65
- panelCommand: session.panelCommand,
71
+ // The shared list overlay (`u` history / `⇧T` trash).
72
+ overlay: session.overlay,
73
+ overlayIndex: session.overlayIndex,
74
+ overlayItems: session.overlayItems,
66
75
  panelDraft: session.panelDraft,
67
76
  panelCursorRow: session.panelCursorRow,
68
77
  panelEntry: session.panelEntry,
@@ -70,13 +79,12 @@ function createInit(session) {
70
79
  panelFocus: session.panelFocus,
71
80
  panelOpIndex: session.panelOpIndex,
72
81
  panelItems: session.panelItems,
73
- panelLabelWidth: session.panelLabelWidth,
74
82
  panelOpen: session.panelOpen,
75
- panelPicking: session.panelPicking,
83
+ panelPermBit: session.panelPermBit,
84
+ panelPreview: session.panelPreview,
76
85
  panelQuery: session.panelQuery,
77
86
  panelRowIndex: session.panelRowIndex,
78
87
  panelRows: session.panelRows,
79
- panelRunnable: session.panelRunnable,
80
88
  panelTextual: session.panelTextual,
81
89
  panelValue: session.panelValue,
82
90
  // The explorer's first column: the parent of the current directory.
@@ -94,6 +102,8 @@ function createInit(session) {
94
102
  parentSetFilter: (value) => parent.setFilter(value),
95
103
  preview: session.preview,
96
104
  previewFull: session.previewFull,
105
+ // The full previews apply body scroll requests through this cell.
106
+ previewScroll: session.previewScroll,
97
107
  // The third column: the directory the inspector previews, so it reads
98
108
  // as a pane. Over a file the inspector stays a card and these idle.
99
109
  previewActive,
@@ -117,9 +127,12 @@ function createInit(session) {
117
127
  rightSetFilter: (value) => right.setFilter(value),
118
128
  showPreview: session.showPreview,
119
129
  viewMode: session.viewMode,
120
- // The panel's file picker: its filter input and the directory it
121
- // currently browses, so the panel can render the picker view.
122
- pickerPath: session.pickerPane?.path,
130
+ // The panel's file picker: its open mode, editable path, focus region
131
+ // and rows, so the panel can render the picker view.
132
+ pickerOpen: session.pickerOpen,
133
+ pickerPathText: session.pickerPathText,
134
+ pickerFocus: session.pickerFocus,
135
+ pickerRows: session.pickerRows,
123
136
  pickerQuery: session.pickerQuery,
124
137
  })
125
138
  }
@@ -134,15 +147,58 @@ function fieldOwnsKeys(data, focus) {
134
147
  return focus === PANEL_FOCUS.OPS || Boolean(unwrap(data.panelTextual))
135
148
  }
136
149
 
150
+ // Function: isFilePreview
151
+ // True when the inspector shows a file — a text body or a metadata card — as
152
+ // opposed to a directory column, which keeps its listing cursor navigation.
153
+ function isFilePreview(data) {
154
+ const entry = unwrap(data.appEntry)
155
+ return Boolean(entry) && !entry.isDir
156
+ }
157
+
158
+ // Function: routePreviewKeys
159
+ // The arrows of a full preview: Up/Down scroll the body, Left/Right step to
160
+ // the previous/next entry (the cursor movement the preview already follows).
161
+ // Returns true when the key was handled, so the caller can stop routing.
162
+ function routePreviewKeys(data, key) {
163
+ if (!isFilePreview(data)) return false
164
+ const name = key?.name
165
+ if (name === "up" || name === "down") {
166
+ data.dispatch("preview.scroll", { delta: name === "up" ? -1 : 1 })
167
+ return true
168
+ }
169
+ if (name === "left" || name === "right") {
170
+ data.dispatch(name === "left" ? "nav.up" : "nav.down")
171
+ return true
172
+ }
173
+ return false
174
+ }
175
+
137
176
  // Function: createBehaviors
138
177
  // Builds the App data/key behavior map for the given renderer environment.
139
178
  // Published events are registered separately via `createEvents()` (`.sub`).
140
179
  function createBehaviors(env) {
141
180
  return {
142
- // Global key handling: confirm dialogs first, the command panel, then
143
- // the keymap.
181
+ // Global key handling: the fullscreen viewer modal first, then confirm
182
+ // dialogs, the command panel, and finally the keymap.
144
183
  keys: (self, data, event) => {
145
184
  const key = env.normalizeKey ? env.normalizeKey(event) : event
185
+ // The fullscreen inspector overlay is modal at any width: it opens
186
+ // from Enter on a file the host cannot edit (see `core/commands`)
187
+ // and from `v` below the inline breakpoint (below). While open it
188
+ // owns the keyboard — Esc or `v` closes it, ⇧O/⇧E act on the file,
189
+ // everything else is swallowed.
190
+ if (unwrap(data.viewerOpen)) {
191
+ if (key?.name === "escape" || (key?.name === "v" && !key.shift)) {
192
+ data.dispatch("view.toggleViewer")
193
+ } else if (key?.shift && key?.name === "o") {
194
+ data.dispatch("file.open", { paneId: unwrap(data.active) })
195
+ } else if (key?.shift && key?.name === "e") {
196
+ data.dispatch("file.edit", { paneId: unwrap(data.active) })
197
+ } else {
198
+ routePreviewKeys(data, key)
199
+ }
200
+ return
201
+ }
146
202
  if (unwrap(data.confirm)) {
147
203
  const name = key?.name
148
204
  if (name === "y" || name === "return" || name === "enter") data.dispatch("confirm.yes")
@@ -150,41 +206,63 @@ function createBehaviors(env) {
150
206
  return
151
207
  }
152
208
  if (unwrap(data.panelOpen)) {
153
- // The picker is a sub-mode of the panel: it owns navigation and
154
- // choice, while typing filters through its own input.
155
- if (unwrap(data.panelPicking)) {
209
+ // The picker is a sub-mode of the panel: its editable path owns
210
+ // typing, and the entry list owns navigation once focused.
211
+ if (unwrap(data.pickerOpen)) {
212
+ const field = unwrap(data.pickerFocus) === "rows" ? "rows" : "path"
156
213
  if (key?.name === "escape") {
157
- data.dispatch("panel.pick.cancel")
214
+ data.dispatch("picker.cancel")
158
215
  return
159
216
  }
160
217
  if (key?.name === "tab") {
161
- data.dispatch("panel.pick.cancel")
218
+ data.dispatch(field === "path" ? "picker.focusRows" : "picker.focusPath")
219
+ return
220
+ }
221
+ if (field === "path") {
222
+ // Typing, Backspace and caret movement belong to the path
223
+ // input. Up/Down hand over to the entries, Enter commits
224
+ // the typed path.
225
+ if (key?.name === "up" || key?.name === "down") {
226
+ data.dispatch("picker.focusRows")
227
+ return
228
+ }
229
+ if (key?.name === "return" || key?.name === "enter") {
230
+ data.dispatch("picker.path")
231
+ return
232
+ }
162
233
  return
163
234
  }
164
235
  if (key?.name === "up") {
165
- data.dispatch("panel.pick.up")
236
+ data.dispatch("picker.up")
166
237
  return
167
238
  }
168
239
  if (key?.name === "down") {
169
- data.dispatch("panel.pick.down")
240
+ data.dispatch("picker.down")
241
+ return
242
+ }
243
+ if (key?.name === "left") {
244
+ data.dispatch("picker.focusPath")
170
245
  return
171
246
  }
172
247
  if (key?.name === "right") {
173
- data.dispatch("panel.pick.descend")
248
+ data.dispatch("picker.descend")
174
249
  return
175
250
  }
176
251
  if (key?.name === "backspace") {
177
- // An empty filter steps up; otherwise the filter input
178
- // owns Backspace and edits itself.
179
- if (`${unwrap(data.pickerQuery) ?? ""}` === "") data.dispatch("panel.pick.parent")
252
+ // An empty path (the current directory) steps up; a
253
+ // typed fragment is edited in the path input instead.
254
+ if (`${unwrap(data.pickerQuery) ?? ""}` === "") data.dispatch("picker.parent")
180
255
  return
181
256
  }
182
257
  if (key?.name === "return" || key?.name === "enter") {
183
- data.dispatch(key.ctrl ? "panel.pick.chooseDir" : "panel.pick.enter")
258
+ data.dispatch(key.ctrl ? "picker.chooseDir" : "picker.enter")
184
259
  return
185
260
  }
186
- // Everything else (typing, caret movement) is the filter
187
- // input's to handle.
261
+ // Typing anywhere in the picker narrows the path.
262
+ const char = key?.name === "space" ? " " : key?.name
263
+ if (char && char.length === 1 && !key.ctrl && !key.meta) {
264
+ data.dispatch("picker.type", { char })
265
+ }
188
266
  return
189
267
  }
190
268
  const focus = unwrap(data.panelFocus)
@@ -225,6 +303,11 @@ function createBehaviors(env) {
225
303
  // it. Intercepting Backspace here used to bounce the cursor
226
304
  // back to the command filter mid-edit.
227
305
  if (fieldOwns) return
306
+ // A focused permission row: Left/Right walk its r/w/x cells.
307
+ if ((key?.name === "left" || key?.name === "right") && focus === PANEL_FOCUS.ARGS) {
308
+ data.dispatch("panel.moveBit", { delta: key.name === "left" ? -1 : 1 })
309
+ return
310
+ }
228
311
  // A focused non-textual argument row: Space toggles its
229
312
  // selection, any other printable key jumps back to the command
230
313
  // filter and appends the character; Backspace edits the filter.
@@ -243,20 +326,14 @@ function createBehaviors(env) {
243
326
  return
244
327
  }
245
328
  if (unwrap(data.bookmarkOpen)) {
246
- const mode = unwrap(data.bookmarkMode)
329
+ // One panel: the filter input owns typing, arrows move the
330
+ // list, Enter activates the row. Backspace edits the query while
331
+ // it has text and removes the selected bookmark once it is
332
+ // empty; Delete always removes.
247
333
  if (key?.name === "escape") {
248
334
  data.dispatch("bookmark.cancel")
249
335
  return
250
336
  }
251
- if (mode === "name") {
252
- // The input owns typing; Enter saves.
253
- if (key?.name === "return" || key?.name === "enter") {
254
- self.pub("bookmark:confirm")
255
- return
256
- }
257
- return
258
- }
259
- // List mode: arrows move, Enter jumps.
260
337
  if (key?.name === "up") {
261
338
  data.dispatch("bookmark.up")
262
339
  return
@@ -265,11 +342,42 @@ function createBehaviors(env) {
265
342
  data.dispatch("bookmark.down")
266
343
  return
267
344
  }
345
+ if (key?.name === "delete") {
346
+ data.dispatch("bookmark.remove")
347
+ return
348
+ }
349
+ if (key?.name === "backspace" && !`${unwrap(data.bookmarkQuery) ?? ""}`.trim()) {
350
+ data.dispatch("bookmark.remove")
351
+ return
352
+ }
268
353
  if (key?.name === "return" || key?.name === "enter") {
269
354
  self.pub("bookmark:confirm")
270
355
  return
271
356
  }
272
- // Let the focused input handle typing the fuzzy query.
357
+ // Let the focused input handle typing the filter.
358
+ return
359
+ }
360
+ // The shared list overlay (history / trash) is modal: arrows move,
361
+ // Enter activates the row (jump in history, restore from trash), Esc
362
+ // closes. The key that opened it also closes it.
363
+ if (unwrap(data.overlay)) {
364
+ const mode = unwrap(data.overlay)
365
+ if (key?.name === "escape" || (key?.name === "u" && mode === "history") || (key?.name === "t" && key?.shift && mode === "trash")) {
366
+ data.dispatch("overlay.cancel")
367
+ return
368
+ }
369
+ if (key?.name === "up") {
370
+ data.dispatch("overlay.up")
371
+ return
372
+ }
373
+ if (key?.name === "down") {
374
+ data.dispatch("overlay.down")
375
+ return
376
+ }
377
+ if (key?.name === "return" || key?.name === "enter") {
378
+ data.dispatch("overlay.activate")
379
+ return
380
+ }
273
381
  return
274
382
  }
275
383
  const active = unwrap(data.active)
@@ -316,6 +424,17 @@ function createBehaviors(env) {
316
424
  return
317
425
  }
318
426
  }
427
+ // The maximized inspector (`⇧V`) previews a file full-width: the
428
+ // arrows scroll and step through files instead of driving the pane
429
+ // underneath. A directory column keeps its listing navigation.
430
+ if (unwrap(data.previewFull) && routePreviewKeys(data, key)) return
431
+ // Below the inline breakpoint the viewer overlay replaces the side
432
+ // panel, so `v` opens it instead of toggling `showPreview` (⇧V
433
+ // still maximizes over the columns, via the keymap).
434
+ if (key?.name === "v" && !key?.shift && isNarrow(data, self)) {
435
+ data.dispatch("view.toggleViewer")
436
+ return
437
+ }
319
438
  const id = matchKeymap(DEFAULT_KEYMAP, key)
320
439
  if (!id) return
321
440
  if (id === "app.quit") {
@@ -329,14 +448,17 @@ function createBehaviors(env) {
329
448
 
330
449
  // Function: createEvents
331
450
  // App event subscriptions, registered with `.sub(...)`. Handlers receive
332
- // `(self, data, event)` with the published payload in `event.data`. The
333
- // environment carries the renderer's `quit` hook, so the command bar's Quit
334
- // entry takes the same path as the `q` key.
335
- function createEvents(env = {}) {
451
+ // `(self, data, event)` with the published payload in `event.data`.
452
+ function createEvents() {
453
+ // Last listing row clicked, for the double-click-to-open gesture: a second
454
+ // click on the same row within `DOUBLE_CLICK_MS` opens it like Enter.
455
+ let lastRowClick = { paneId: null, index: -1, at: 0 }
336
456
  return {
337
- "commandbar:run": (_self, data, event) => {
338
- if (event.data === "app.quit") {
339
- env.quit?.(_self)
457
+ "commandbar:run": (self, data, event) => {
458
+ // Below the inline breakpoint the bar's `[Preview v]` opens the
459
+ // viewer overlay, the same as the key (see `createBehaviors#keys`).
460
+ if (event.data === "view.togglePreview" && isNarrow(data, self)) {
461
+ data.dispatch("view.toggleViewer")
340
462
  return
341
463
  }
342
464
  if (event.data) data.dispatch(event.data)
@@ -344,9 +466,9 @@ function createEvents(env = {}) {
344
466
  "panel:select": (_self, data, event) => {
345
467
  // Clicking a command row moves the cursor to it (a group header is
346
468
  // inert); clicking an argument/item row focuses the right pane
347
- // (toggling an item); clicking a picker row chooses it.
469
+ // (toggling an item); clicking a picker row chooses or navigates it.
348
470
  const items = unwrap(data.panelItems) ?? []
349
- const item = items.find((row) => row.id === event.data)
471
+ const item = items.find((row) => row.key === event.data)
350
472
  if (item?.type === "op") {
351
473
  data.dispatch("panel.setOp", { index: item.entryIndex })
352
474
  return
@@ -356,50 +478,103 @@ function createEvents(env = {}) {
356
478
  const rowIndex = rows.findIndex((row) => row.key === event.data)
357
479
  if (rowIndex < 0) return
358
480
  const row = rows[rowIndex]
359
- if (row?.type === "entry") {
481
+ if (row?.type === "entry" || row?.type === "parent") {
360
482
  data.dispatch("panel.focusArg")
361
- data.dispatch("panel.pick.setCursor", { index: rowIndex })
362
- data.dispatch("panel.pick.enter")
483
+ data.dispatch("picker.setCursor", { index: rowIndex })
484
+ data.dispatch(row.type === "parent" ? "picker.parent" : "picker.enter")
363
485
  return
364
486
  }
487
+ if (row?.type === "run") {
488
+ data.dispatch("panel.focusArg")
489
+ data.dispatch("panel.setRow", { index: rowIndex })
490
+ data.dispatch("panel.run")
491
+ return
492
+ }
493
+ // A read-only value (a single-path selection, resolved from the
494
+ // pane) is a path worth copying: clicking it sends the value to the
495
+ // host clipboard. Copy runs last so its confirmation is not cleared
496
+ // by the focus dispatches (each dispatch resets the notice).
365
497
  data.dispatch("panel.focusArg")
366
498
  data.dispatch("panel.setRow", { index: rowIndex })
367
499
  if (row?.type === "item") data.dispatch("panel.toggleItem")
500
+ if (row?.type === "arg" && row.readonly === true && row.summary) {
501
+ data.dispatch("panel.copy", { text: row.summary })
502
+ }
368
503
  },
369
504
  "panel:confirm": (_self, data) => {
370
505
  // Published by a focused panel input's `on:enter`; the key handler
371
506
  // leaves Enter to it (see `fieldOwnsKeys`).
372
507
  if (!unwrap(data.panelOpen)) return
373
- if (unwrap(data.panelPicking)) {
374
- data.dispatch("panel.pick.enter")
508
+ if (unwrap(data.pickerOpen)) {
509
+ data.dispatch("picker.enter")
375
510
  return
376
511
  }
377
512
  data.dispatch("panel.run")
378
513
  },
514
+ "panel:perm": (_self, data, event) => {
515
+ // A click on one r/w/x cell: focus its class row, then toggle the
516
+ // bit. The payload is `argIndex:itemIndex:rowIndex` (see
517
+ // `core/panel#panelRowList`).
518
+ const [argIndex, itemIndex, rowIndex] = `${event.data ?? ""}`.split(":").map(Number)
519
+ if (![argIndex, itemIndex, rowIndex].every(Number.isInteger)) return
520
+ data.dispatch("panel.focusArg")
521
+ data.dispatch("panel.setRow", { index: rowIndex })
522
+ data.dispatch("panel.togglePerm", { argIndex, itemIndex })
523
+ },
524
+ "panel:close": (_self, data) => {
525
+ // The dialog's upper-right close shorthand: back out of the picker
526
+ // sub-mode first, otherwise close the panel.
527
+ if (!unwrap(data.panelOpen)) return
528
+ data.dispatch(unwrap(data.pickerOpen) ? "picker.cancel" : "panel.cancel")
529
+ },
379
530
  "crumbs:go": (_self, data, event) => {
380
531
  // The "…" truncation marker carries no path.
381
532
  if (!event.data?.path) return
382
533
  data.dispatch("pane.setPath", event.data)
383
534
  },
384
- "list:select": (_self, data, event) => data.dispatch("list.cursor", event.data),
535
+ "list:select": (_self, data, event) => {
536
+ const paneId = event.data?.paneId
537
+ const index = event.data?.index
538
+ const now = Date.now()
539
+ // A click selects (focus + cursor); a second click on the same row
540
+ // within the window opens it like Enter. `list.cursor` activates the
541
+ // pane synchronously, so `pane.enter` then acts on the clicked pane.
542
+ const isDouble =
543
+ paneId === lastRowClick.paneId &&
544
+ index === lastRowClick.index &&
545
+ now - lastRowClick.at <= DOUBLE_CLICK_MS
546
+ lastRowClick = { paneId, index, at: isDouble ? 0 : now }
547
+ data.dispatch("list.cursor", event.data)
548
+ if (isDouble) data.dispatch("pane.enter", { paneId })
549
+ },
385
550
  "pane:activate": (_self, data, event) => data.dispatch("pane.activate", { paneId: event.data }),
551
+ // Clicking the identity bar's view-mode word flips explorer/commander.
552
+ "view:toggleMode": (_self, data) => data.dispatch("view.toggleMode"),
386
553
  "bookmark:select": (_self, data, event) => {
387
- // Clicking a bookmark row selects and jumps to it.
554
+ // Clicking a bookmark (or the add) row selects and activates it.
388
555
  if (!unwrap(data.bookmarkOpen)) return
389
556
  const items = unwrap(data.bookmarkItems) ?? []
390
- const index = items.findIndex((row) => row.name === event.data)
557
+ const index = items.findIndex((row) => row.key === event.data)
391
558
  if (index < 0) return
392
559
  data.dispatch("bookmark.setIndex", { index })
393
560
  data.dispatch("bookmark.select")
394
561
  },
395
562
  "bookmark:confirm": (_self, data) => {
396
563
  if (!unwrap(data.bookmarkOpen)) return
397
- if (unwrap(data.bookmarkMode) === "name") data.dispatch("bookmark.save")
398
- else data.dispatch("bookmark.select")
564
+ data.dispatch("bookmark.select")
565
+ },
566
+ "overlay:select": (_self, data, event) => {
567
+ // Clicking an overlay row moves the cursor to it and activates it.
568
+ if (!unwrap(data.overlay)) return
569
+ const rows = unwrap(data.overlayItems) ?? []
570
+ const index = rows.findIndex((row) => row.key === event.data)
571
+ if (index < 0) return
572
+ data.dispatch("overlay.setIndex", { index })
573
+ data.dispatch("overlay.activate")
399
574
  },
400
575
  }
401
576
  }
402
577
 
403
- export { createBehaviors, createEvents, createInit }
578
+ export { createBehaviors, createEvents, createInit, DOUBLE_CLICK_MS }
404
579
 
405
580
  // EOF