@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,10 @@
1
+ <div class="panel-picker-pane" when="open" aria-label="File picker">
2
+ <div class="panel-picker">
3
+ <label class="panel-picker-line">
4
+ <svg class="panel-picker-icon ui-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use href="#lucide-folder-open"></use></svg>
5
+ <input class="panel-picker-path" type="text" inout="path" aria-label="Path" spellcheck="false" autocomplete="off" />
6
+ </label>
7
+ </div>
8
+ <ul class="panel-args panel-picker-rows" aria-label="Picker entries" out="rows"></ul>
9
+ <p class="panel-args-empty" when="empty" out="emptyText"></p>
10
+ </div>
@@ -0,0 +1,43 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-20
5
+
6
+ // Module: tcmd/components/FilePicker
7
+ // The command panel's file picker: an editable path line over a file-panel
8
+ // listing (`FilePickerRow`), with the `..` parent row kept at the top. Typing
9
+ // in the path narrows the listing (or enters a directory) through the session's
10
+ // path resolution; the row logic lives in `FilePickerRow.js`.
11
+ //
12
+ // Environment: `rows(self, data)` returning keyed `FilePickerRow`s and
13
+ // `scrollTop(self, data)` (TUI viewport tracking).
14
+
15
+ import { unwrap } from "select/state/cells"
16
+
17
+ // Function: createBehaviors
18
+ // Builds the FilePicker behavior map for the given renderer environment.
19
+ function createBehaviors(env) {
20
+ const open = (_self, data) => Boolean(unwrap(data.open))
21
+ const behaviors = {
22
+ open,
23
+ // The editable path owns the keyboard by default; Down/Tab hands it to
24
+ // the entry list.
25
+ pathFocused: (_self, data) => open(_self, data) && (unwrap(data.focus) ?? "path") === "path",
26
+ pathSign: () => "› ",
27
+ pathSignFg: () => env.accentFg,
28
+ // The parent row is a navigation row, not a match: an empty listing is
29
+ // an empty result even while `..` stays visible.
30
+ empty: (_self, data) =>
31
+ open(_self, data) && !(unwrap(data.rows) ?? []).some((row) => row.type === "entry"),
32
+ emptyText: () => " no matches",
33
+ emptyFg: () => env.textSecondaryFg,
34
+ rule: () => ["bottom"],
35
+ ruleFg: () => env.borderSecondaryFg,
36
+ borderFg: () => env.borderSecondaryFg,
37
+ }
38
+ if (env?.rows) behaviors.rows = (self, data) => env.rows(self, data)
39
+ if (env?.scrollTop) behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
40
+ return behaviors
41
+ }
42
+
43
+ export { createBehaviors }
@@ -0,0 +1,13 @@
1
+ <box when="open" flexDirection="column" flexGrow="1" flexShrink="1" minHeight="0" paddingLeft="1" paddingRight="1" paddingBottom="1">
2
+ <box flexDirection="column" flexGrow="1" flexShrink="1" minHeight="0" border borderStyle="single" out:borderColor="borderFg">
3
+ <box flexDirection="row" height="1" paddingLeft="1" paddingRight="1" overflow="hidden">
4
+ <text out="pathSign" out:fg="pathSignFg" wrapMode="none"></text>
5
+ <input ref="path" inout="path" flexGrow="1" minWidth="0" height="1" out:focused="pathFocused"></input>
6
+ </box>
7
+ <box height="1" out:border="rule" out:borderColor="ruleFg"></box>
8
+ <scrollbox ref="list" flexGrow="1" flexShrink="1" minHeight="0" out:scrollTop="scrollTop">
9
+ <box out="rows" flexDirection="column"></box>
10
+ </scrollbox>
11
+ <text when="empty" out="emptyText" out:fg="emptyFg" paddingLeft="1" wrapMode="none"></text>
12
+ </box>
13
+ </box>
@@ -0,0 +1,6 @@
1
+ <li class="file-row picker-row" out:class="rowClass" out:data-marked="rowMarked" out:data-active="rowActive" out:data-kind="rowKind" out:data-type="rowType" out:data-key="rowHref" on:click="key!panel:select">
2
+ <span class="marker" out="rowMarker"></span>
3
+ <svg class="file-row-icon ui-icon" when="hasIcon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use out:href="rowIconHref"></use></svg>
4
+ <span class="name" out:class="rowNameClass" out="rowName"></span>
5
+ <span class="size" out="rowSize"></span>
6
+ </li>
@@ -0,0 +1,27 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-20
5
+
6
+ // Module: tcmd/components/FilePickerRow
7
+ // One entry in the file picker. The row presentation deliberately mirrors the
8
+ // file panes (`FileRow`): marker, kind icon, name and right-aligned size. It
9
+ // only overrides the `..` parent row (no trailing slash) and its click event,
10
+ // which the command panel routes back through `panel:select`.
11
+
12
+ import { createBehaviors as fileRowBehaviors, rowName as fileRowName } from "./FileRow.js"
13
+
14
+ // Function: createBehaviors
15
+ // Builds the FilePickerRow behavior map from the shared FileRow behaviors.
16
+ function createBehaviors(env) {
17
+ const behaviors = fileRowBehaviors(env)
18
+ return {
19
+ ...behaviors,
20
+ rowName: (_self, data) => (data.item?.isParent ? ".." : fileRowName(data.item)),
21
+ rowSize: (_self, data) => (data.item?.isParent ? "" : behaviors.rowSize?.(_self, data) ?? ""),
22
+ rowType: (_self, data) => (data.item?.isParent ? "parent" : data.item?.type ?? "entry"),
23
+ rowHref: (_self, data) => data.key ?? data.item?.path ?? "",
24
+ }
25
+ }
26
+
27
+ export { createBehaviors }
@@ -0,0 +1,10 @@
1
+ <box flexDirection="row" height="1" columnGap="1" out:backgroundColor="rowBg" on:click="key!panel:select">
2
+ <text out="rowMarker" width="2" out:fg="rowMarkerFg"></text>
3
+ <box flexGrow="1" minWidth="0" overflow="hidden" flexDirection="row" columnGap="1">
4
+ <text out="rowIcon" out:fg="rowIconFg" wrapMode="none" when="hasIcon"></text>
5
+ <text out="rowName" out:fg="rowNameFg" wrapMode="none" truncate="true"></text>
6
+ </box>
7
+ <box width="9" justifyContent="flex-end">
8
+ <text out="rowSize" out:fg="rowSizeFg" wrapMode="none"></text>
9
+ </box>
10
+ </box>
@@ -2,7 +2,7 @@
2
2
  (breadcrumbs + cursorable listing) for a directory, so a previewed
3
3
  directory reads as a pane. The body and the column share the frame, and
4
4
  the whole column is clickable to take focus. -->
5
- <aside class="preview" when="show" out:title="headerTitle" on:click="paneId!pane:activate">
5
+ <aside class="preview" when="show" out:title="headerTitle" on:click="paneId!pane:activate.">
6
6
  <header when="showBody" out="headerTitle"></header>
7
7
  <div class="pane-header" when="isDirPane">
8
8
  <div out="dirCrumbsView|Breadcrumbs"></div>
@@ -12,5 +12,4 @@
12
12
  <pre when="showCard" out="cardText" class="preview-card"></pre>
13
13
  <pre when="showEntryCard" out="entryText" class="preview-card"></pre>
14
14
  <pre when="showEmpty" out="emptyText" class="preview-card"></pre>
15
- <footer class="preview-actions" out="actions"></footer>
16
15
  </aside>
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { unwrap } from "select/state/cells"
23
23
  import { formatSize } from "./format.js"
24
- import { previewSource } from "../core/session.js"
24
+ import { previewSource } from "../core/preview.js"
25
25
 
26
26
  const KIND_LABELS = {
27
27
  directory: "directory",
@@ -58,11 +58,13 @@ function hasText(preview) {
58
58
 
59
59
  // Function: previewTitle
60
60
  // "Filename <EXT>" style title; falls back to the kind label when the file has
61
- // no extension.
61
+ // no extension. A decompressed preview adds the inner label, so
62
+ // `notes.txt.gz` reads `notes.txt.gz <GZ · TXT>`.
62
63
  function previewTitle(preview) {
63
64
  if (!preview) return "preview"
64
65
  const badge = (preview.ext || KIND_LABELS[preview.kind] || "file").toUpperCase()
65
- return `${preview.title} <${badge}>`
66
+ const inner = preview.compression && preview.innerExt ? ` · ${preview.innerExt.toUpperCase()}` : ""
67
+ return `${preview.title} <${badge}${inner}>`
66
68
  }
67
69
 
68
70
  // Function: entryCard
@@ -97,9 +99,13 @@ function headerTitle(preview, entry) {
97
99
  if (entry) {
98
100
  const source = previewSource(entry)
99
101
  const badge = entry.isDir ? "DIR" : (source.ext || "file").toUpperCase()
102
+ const inner =
103
+ !entry.isDir && source.compression && source.innerExt
104
+ ? ` · ${source.innerExt.toUpperCase()}`
105
+ : ""
100
106
  const name = entry.isDir ? `${entry.name}/` : entry.name
101
107
  const target = entry.isSymlink && entry.target ? ` -> ${entry.target}` : ""
102
- return `${name}${target} <${badge}>`
108
+ return `${name}${target} <${badge}${inner}>`
103
109
  }
104
110
  return "inspector"
105
111
  }
@@ -113,10 +119,32 @@ function createBehaviors(env) {
113
119
  const directory = (_self, data) =>
114
120
  isDirectoryPane(unwrap(data.preview), unwrap(data.entry))
115
121
  const body = (_self, data) => !directory(_self, data)
122
+ // An image the renderer can actually decode: the card stays whenever the
123
+ // preview carries `stat` details, or the entry sits behind the cursor as a
124
+ // directory. `imageExts` is renderer knowledge (the terminal image path
125
+ // decodes the raster formats only), so it comes from the environment and
126
+ // the web — which has no image renderable — simply never matches.
127
+ const isImage = (_self, data) => {
128
+ const preview = unwrap(data.preview)
129
+ const entry = unwrap(data.entry)
130
+ if (preview?.kind !== "image" || preview.details || !entry?.path) return false
131
+ const ext = `${preview.ext ?? entry.ext ?? ""}`.toLowerCase()
132
+ return (env.imageExts ?? []).includes(ext)
133
+ }
116
134
  return {
117
135
  actions,
118
136
  actionsFg: () => env.textSecondaryFg,
119
137
  cardText: (_self, data) => cardText(unwrap(data.preview)),
138
+ // Tree-sitter filetype for the previewed text, resolved by the
139
+ // renderer (the web has no highlighter and supplies no `filetype`).
140
+ // A compressed preview highlights the name inside, so `.txt.gz`
141
+ // still lands on the text parser.
142
+ codeFiletype: (_self, data) => {
143
+ const entry = unwrap(data.entry)
144
+ if (!entry?.path) return undefined
145
+ const source = previewSource(entry)
146
+ return env.filetype?.(source.compression ? source.innerName : entry.path)
147
+ },
120
148
  // The directory column: breadcrumbs over the inspector pane's listing,
121
149
  // shaped like a pane header (a rule separates it from the body).
122
150
  dirCrumbsView: (_self, data) => ({ segments: data.dirSegments, paneId: data.paneId }),
@@ -141,6 +169,10 @@ function createBehaviors(env) {
141
169
  headerTitle: (_self, data) => headerTitle(unwrap(data.preview), unwrap(data.entry)),
142
170
  isEmpty: (_self, data) => !unwrap(data.preview) && !unwrap(data.entry),
143
171
  isDirPane: directory,
172
+ // The path the image renderable loads (symlinks read through, like the
173
+ // text body).
174
+ previewImageSource: (_self, data) =>
175
+ isImage(_self, data) ? unwrap(data.entry)?.path : undefined,
144
176
  previewText: (_self, data) => unwrap(data.preview)?.text ?? "",
145
177
  previewTitle: (_self, data) => previewTitle(unwrap(data.preview)),
146
178
  previewTitleColor: () => env.textHeadingFg,
@@ -154,7 +186,11 @@ function createBehaviors(env) {
154
186
  showEmpty: (_self, data) => body(_self, data) && !unwrap(data.preview) && !unwrap(data.entry),
155
187
  showEntryCard: (_self, data) =>
156
188
  body(_self, data) && !unwrap(data.preview) && Boolean(unwrap(data.entry)),
189
+ // The image takes the body on its own; when the format has no decoder
190
+ // (or the preview is a `stat` card) the scrollbox carries the body.
191
+ showImage: (_self, data) => body(_self, data) && isImage(_self, data),
157
192
  showText: (_self, data) => body(_self, data) && hasText(unwrap(data.preview)),
193
+ showTextOrCard: (_self, data) => body(_self, data) && !isImage(_self, data),
158
194
  textFg: () => env.textFg,
159
195
  }
160
196
  }
@@ -1,7 +1,9 @@
1
1
  <!-- The inspector: a card/text body for a file, and a directory column
2
2
  (breadcrumbs + cursorable listing) for a directory, so a previewed
3
- directory reads as a pane. The body and the column share the border and
4
- the footer, and the whole column is clickable to take focus. -->
3
+ directory reads as a pane. A text body highlights through the `code`
4
+ tree-sitter renderable; an image draws through the `image` renderable's
5
+ terminal graphics protocol. The body and the column share the border, and
6
+ the whole column is clickable to take focus. -->
5
7
  <box when="show" out:title="headerTitle" out:titleColor="previewTitleColor" border borderStyle="rounded"
6
8
  flexDirection="column" flexGrow="1" overflow="hidden" on:click="paneId!pane:activate">
7
9
  <box when="isDirPane" flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
@@ -12,12 +14,17 @@
12
14
  <box when="isDirPane" height="1" out:border="headRule" out:borderColor="headRuleFg"></box>
13
15
  <filelist when="isDirPane" in:entries="dirEntries" in:cursor="dirCursor" in:marked="dirMarked"
14
16
  in:active="dirActive" in:paneId="paneId" in:loading="dirLoading"></filelist>
15
- <scrollbox when="showBody" flexGrow="1">
16
- <text when="showText" out="previewText" out:fg="textFg"></text>
17
- <text when="showCard" out="cardText" out:fg="dimFg"></text>
18
- <text when="showEntryCard" out="entryText" out:fg="dimFg"></text>
19
- <text when="showEmpty" out="emptyText" out:fg="dimFg"></text>
20
- </scrollbox>
21
- <box height="1" out:border="footRule" out:borderColor="footRuleFg"></box>
22
- <box height="1"><text out="actions" out:fg="actionsFg"></text></box>
17
+ <box when="showBody" flexGrow="1" position="relative" overflow="hidden">
18
+ <scrollbox when="showTextOrCard" ref="body" flexGrow="1">
19
+ <code when="showText" out="previewText" out:fg="textFg" out:filetype="codeFiletype"></code>
20
+ <text when="showCard" out="cardText" out:fg="dimFg"></text>
21
+ <text when="showEntryCard" out="entryText" out:fg="dimFg"></text>
22
+ <text when="showEmpty" out="emptyText" out:fg="dimFg"></text>
23
+ </scrollbox>
24
+ <!-- Image files draw through the terminal's graphics protocol
25
+ (Kitty/sixel, half-block fallback). Formats with no decoder
26
+ (`bmp`, `svg`) keep the metadata card instead. -->
27
+ <image when="showImage" position="absolute" top="0" left="0" width="100%" height="100%"
28
+ fit="fit" protocol="auto" out:source="previewImageSource"></image>
29
+ </box>
23
30
  </box>
@@ -1,5 +1,6 @@
1
1
  <li class="file-row" out:class="rowClass" out:data-marked="rowMarked" out:data-active="rowActive" out:data-kind="rowKind" on:click="paneId:paneId,index:index!list:select">
2
2
  <span class="marker" out="rowMarker"></span>
3
+ <svg class="file-row-icon ui-icon" when="hasIcon" aria-hidden="true" focusable="false" viewBox="0 0 24 24"><use out:href="rowIconHref"></use></svg>
3
4
  <span class="name" out:class="rowNameClass" out="rowName"></span>
4
5
  <span class="size" out="rowSize"></span>
5
6
  </li>
@@ -12,12 +12,13 @@
12
12
  // name (`notes -> ../real/notes`, see `rowName`).
13
13
  //
14
14
  // Environment: `cursorBg`, `cursorInactiveBg`, `currentClass`,
15
- // `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `fileFg`,
16
- // `textSecondaryFg`, `dirClass`, `icons` (show Nerd Font icons).
15
+ // `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `execFg`,
16
+ // `readonlyFg`, `fileFg`, `textSecondaryFg`, `dirClass`, `execClass`,
17
+ // `readonlyClass`, `icons` (show Nerd Font icons).
17
18
 
18
19
  import { unwrap } from "select/state/cells"
19
20
  import { formatSize } from "./format.js"
20
- import { iconFor } from "./icons.js"
21
+ import { iconFor, lucideIconFor } from "./icons.js"
21
22
 
22
23
  // Function: rowName
23
24
  // The entry name with the directory slash (the icon is its own slot, so it
@@ -37,6 +38,12 @@ function rowIcon(entry, icons) {
37
38
  return icons ? iconFor(entry) : ""
38
39
  }
39
40
 
41
+ // Function: rowIconHref
42
+ // The current-file SVG reference used by the browser presentation.
43
+ function rowIconHref(entry, icons) {
44
+ return icons ? `#lucide-${lucideIconFor(entry)}` : ""
45
+ }
46
+
40
47
  // Function: rowSize
41
48
  function rowSize(entry) {
42
49
  if (!entry) return ""
@@ -80,21 +87,64 @@ function rowIsMarked(data) {
80
87
  return isMarked(data.item, unwrap(data.marked))
81
88
  }
82
89
 
90
+ // Function: hasMode
91
+ // Whether the entry carries POSIX mode bits. Backends that report no mode
92
+ // (OPFS, some HTTP hosts) leave it 0, so such entries never read as executable
93
+ // or read-only.
94
+ function hasMode(entry) {
95
+ return Number.isFinite(entry?.mode) && entry.mode > 0
96
+ }
97
+
98
+ // Function: isExecutable
99
+ // Whether the entry is a regular file with an execute bit set (any of the
100
+ // owner/group/other slots). Directories, symlinks and device nodes have their
101
+ // own colours and never read as executable here.
102
+ function isExecutable(entry) {
103
+ if (!entry || entry.isDir || entry.isSymlink || entry.isDevice) return false
104
+ return hasMode(entry) && (entry.mode & 0o111) !== 0
105
+ }
106
+
107
+ // Function: isReadonly
108
+ // Whether the entry is a regular file its owner cannot write (`mode` owner
109
+ // write bit clear). Directories, symlinks and device nodes are excluded, and
110
+ // the web backends, which report no mode, stay unaffected.
111
+ function isReadonly(entry) {
112
+ if (!entry || entry.isDir || entry.isSymlink || entry.isDevice) return false
113
+ return hasMode(entry) && (entry.mode & 0o200) === 0
114
+ }
115
+
83
116
  // Function: nameFg
84
117
  // The listing color for an entry: a symlink reads as a link, a directory as a
85
- // directory, a device node as a device, otherwise the plain file color.
118
+ // directory, a device node as a device, an executable file green, a read-only
119
+ // file dimmer than a plain file, otherwise the plain file color.
86
120
  // Symlinks win over directories, matching `dircolors` (a link to a directory
87
- // renders as a link).
121
+ // renders as a link), and an executable wins over a read-only file, matching
122
+ // `ls --color` (an executable stays green even when it cannot be written).
88
123
  function nameFg(item, env) {
89
124
  if (item?.isSymlink) return env.linkFg
90
125
  if (item?.isDir) return env.dirFg
91
126
  if (item?.isDevice) return env.devFg
127
+ if (isExecutable(item)) return env.execFg
128
+ if (isReadonly(item)) return env.readonlyFg
92
129
  return env.fileFg
93
130
  }
94
131
 
132
+ // Function: nameClass
133
+ // The web state classes for an entry's name, mirroring `nameFg`: `dirClass`
134
+ // for a directory, else the executable/read-only class for a file, else null.
135
+ // The class tokens are absent on the TUI env, which colours through
136
+ // `rowNameFg` instead.
137
+ function nameClass(item, env) {
138
+ if (item?.isDir) return env.dirClass
139
+ if (isExecutable(item)) return env.execClass
140
+ if (isReadonly(item)) return env.readonlyClass
141
+ return null
142
+ }
143
+
95
144
  // Function: rowNameFg
96
145
  // The name color for a row: a marked row reads orange (the selection signal),
97
- // the cursor row the bright heading color, otherwise the entry's kind color.
146
+ // the cursor row the bright heading color, otherwise the entry's own color
147
+ // (kind plus executable/read-only state, see `nameFg`).
98
148
  // Marks win over the cursor so a marked-and-current row still reads as
99
149
  // marked, the same precedence the row marker follows.
100
150
  function rowNameFg(data, env) {
@@ -124,19 +174,20 @@ function createBehaviors(env) {
124
174
  rowKind: (_self, data) => data.item?.isSymlink ? "link" : data.item?.isDir ? "dir" : data.item?.isDevice ? "dev" : "file",
125
175
  // The icon is its own slot so it can sit dimmer than the name; it
126
176
  // collapses when the environment keeps rows icon-free.
127
- hasIcon: () => Boolean(env.icons),
177
+ hasIcon: () => Boolean(env.icons || env.lucideIcons),
128
178
  rowIcon: (_self, data) => rowIcon(data.item, Boolean(env.icons)),
179
+ rowIconHref: (_self, data) => rowIconHref(data.item, Boolean(env.lucideIcons)),
129
180
  rowIconFg: () => env.textSecondaryFg,
130
181
  rowMarker: (_self, data) => rowMarker(data),
131
182
  rowMarkerFg: (_self, data) => (rowIsMarked(data) ? env.accentSecondaryFg : env.accentFg),
132
183
  rowName: (_self, data) => rowName(data.item),
133
- rowNameClass: (_self, data) => (data.item?.isDir ? env.dirClass : null),
184
+ rowNameClass: (_self, data) => nameClass(data.item, env),
134
185
  rowNameFg: (_self, data) => rowNameFg(data, env),
135
186
  rowSize: (_self, data) => rowSize(data.item),
136
187
  rowSizeFg: () => env.textSecondaryFg,
137
188
  }
138
189
  }
139
190
 
140
- export { createBehaviors, isMarked, nameFg, rowIcon, rowName, rowNameFg, rowSize }
191
+ export { createBehaviors, isExecutable, isMarked, isReadonly, nameClass, nameFg, rowIcon, rowIconHref, rowName, rowNameFg, rowSize }
141
192
 
142
193
  // EOF
@@ -12,7 +12,7 @@
12
12
  // reference's text lives with its overlay in `Help.js`.
13
13
 
14
14
  import { unwrap } from "select/state/cells"
15
- import { commandBarMarksItem, commandBarRowCount, commandBarStatus } from "./CommandBar.js"
15
+ import { commandBarStatus } from "./CommandBar.js"
16
16
  import pkg from "../../../../package.json" with { type: "json" }
17
17
 
18
18
  // Constant: VERSION
@@ -92,36 +92,56 @@ function currentColumnId(data) {
92
92
  return `${unwrap(data?.currentId) ?? unwrap(data?.active) ?? "left"}`
93
93
  }
94
94
 
95
- // Function: commandBarRowsFor
96
- // The entries the bar currently shows: the context hints or the `bar`
97
- // commands, plus the active pane's marked count as a trailing entry.
98
- function commandBarRowsFor(data) {
99
- const items = unwrap(data?.commandBarItems) ?? []
100
- const marks = commandBarMarksItem(data)
101
- return marks ? [...items, marks] : items
102
- }
95
+ // Constant: PANE_GROW
96
+ // The explorer's current column is this much wider than the parent/preview
97
+ // mirrors — enough to read as the working pane, not enough to starve its
98
+ // neighbours. Both renderers apply the same ratio (the TUI's pane row growers
99
+ // and the web CSS `.pane.is-major`), so the geometry has one source of truth
100
+ // here.
101
+ const PANE_GROW = 1.35
103
102
 
104
- // Function: commandBarLines
105
- // Rows the wrapped command bar occupies at the current viewport (the bar
106
- // is inset one column from the viewport edge).
107
- function commandBarLines(data, self) {
108
- const width = Math.floor(sizeOf(data, self)?.width ?? 0)
109
- if (width <= 0) return 1
110
- return commandBarRowCount(commandBarRowsFor(data), width - 1)
103
+ // Function: paneColumnWidth
104
+ // The columns a pane's column occupies at viewport `width` for the current
105
+ // view. Explorer leads with the parent mirror and grows the current column by
106
+ // PANE_GROW, so its mirrors are narrower than an even split; commander tiles
107
+ // its two panes evenly. The inline inspector adds an equal column to either.
108
+ // Panes hidden in the view, and an unknown viewport (`width <= 0`), are 0.
109
+ //
110
+ // The caller subtracts its own chrome (border, inset, safety) to get a content
111
+ // budget: feeding every pane an even `width / columns` makes the narrower
112
+ // mirror columns overflow, and the renderer then mangles their breadcrumbs.
113
+ function paneColumnWidth(width, paneId, { viewMode, currentId, showPreview } = {}) {
114
+ const total = Math.floor(width ?? 0)
115
+ if (total <= 0) return 0
116
+ const weights = {}
117
+ if (viewMode === "explorer") {
118
+ weights.parent = 1
119
+ weights[`${currentId}` === "right" ? "right" : "left"] = PANE_GROW
120
+ } else {
121
+ weights.left = 1
122
+ weights.right = 1
123
+ }
124
+ if (Boolean(showPreview) && total >= INSPECTOR_INLINE_WIDTH) weights.preview = 1
125
+ const weight = weights[paneId] ?? 0
126
+ if (weight <= 0) return 0
127
+ const sum = Object.values(weights).reduce((acc, value) => acc + value, 0)
128
+ return Math.floor((total * weight) / sum)
111
129
  }
112
130
 
113
131
  // Function: paneListHeight
114
- // Rows available to a pane's listing: the viewport height minus the identity
115
- // bar (1), the pane's own border (2), the pane header + rule (2), the entry
116
- // detail (2), the summary (1) and the wrapped command bar — eight chrome rows
117
- // plus the bar. Fed to the windowed `filelist` so a large directory mounts
118
- // only the visible slice. `0` means "unknown — render everything to measure".
132
+ // Rows available to a pane's listing: the viewport height minus the app header
133
+ // (1 row in the web UI, 2 for the desktop toolbar), the pane border (2), pane
134
+ // header + rule (2), entry detail (2), and the command bar when visible. Fed to
135
+ // the windowed `filelist` so a large directory mounts only the visible slice.
136
+ // `0` means "unknown — render everything to measure".
119
137
  function paneListHeight(data, self) {
120
138
  const height = Math.floor(sizeOf(data, self)?.height ?? 0)
121
139
  if (height <= 0) return 0
122
- // A notice or error swaps the keys for a one-line status.
123
- const bar = commandBarStatus(data) ? 1 : commandBarLines(data, self)
124
- return Math.max(1, height - 8 - bar)
140
+ const desktop = Boolean(unwrap(data?.desktopMode))
141
+ // A notice or error keeps a one-line status in desktop mode; otherwise that
142
+ // renderer has no bottom command bar at all. The bar is always one line.
143
+ const bar = commandBarStatus(data) || !desktop ? 1 : 0
144
+ return Math.max(1, height - (desktop ? 8 : 7) - bar)
125
145
  }
126
146
 
127
147
  // Function: mixHex
@@ -256,12 +276,14 @@ function createFrameBehaviors(env) {
256
276
  (!isExplorer(data) || currentColumnId(data) === "right") && !unwrap(data?.previewFull),
257
277
 
258
278
  // The inline inspector shows when the preview is on and the viewport is
259
- // wide enough; otherwise `v` opens the fullscreen viewer. Maximizing
260
- // (`⇧V`) replaces the column with `showPreviewFull` below.
279
+ // wide enough; the viewer overlay shows whenever it is open, at any
280
+ // width (`v` on a narrow terminal, or Enter on a file that cannot be
281
+ // edited). Maximizing (`⇧V`) replaces the column with
282
+ // `showPreviewFull` below.
261
283
  inspectorNarrow: (self, data) => isNarrow(data, self),
262
284
  showInspectorInline: (self, data) =>
263
285
  Boolean(unwrap(data.showPreview)) && !isNarrow(data, self) && !unwrap(data?.previewFull),
264
- showViewer: (self, data) => Boolean(unwrap(data.viewerOpen)) && isNarrow(data, self),
286
+ showViewer: (_self, data) => Boolean(unwrap(data.viewerOpen)),
265
287
  // The maximized inspector: the preview fills the pane row in either
266
288
  // view mode, pushed by `view.maximize` (`⇧V`).
267
289
  showPreviewFull: (_self, data) => Boolean(unwrap(data?.previewFull)),
@@ -270,17 +292,19 @@ function createFrameBehaviors(env) {
270
292
  // overlay has a single source of truth.
271
293
  helpOpen: (_self, data) => Boolean(unwrap(data.helpOpen)),
272
294
 
295
+ // The shared list overlay (history / trash) is mounted whenever a mode
296
+ // is set; the component itself gates on `open`.
297
+ overlayOpen: (_self, data) => Boolean(unwrap(data.overlay)),
298
+
273
299
  // The windowed listing height for both panes (they share the row).
274
300
  listViewport: (self, data) => paneListHeight(data, self),
275
301
 
276
- // The command bar's height: one line for a status, otherwise as many
277
- // rows as the wrapped bracket shorthands need.
278
- barHeight: (self, data) => (commandBarStatus(data) ? 1 : commandBarLines(data, self)),
302
+ // The command bar's height: always a single line, status or key hints.
303
+ barHeight: () => 1,
279
304
  }
280
305
  }
281
306
 
282
307
  export {
283
- commandBarLines,
284
308
  createFrameBehaviors,
285
309
  currentColumnId,
286
310
  INSPECTOR_INLINE_WIDTH,
@@ -291,6 +315,8 @@ export {
291
315
  MODE_SLOTS,
292
316
  modeGradient,
293
317
  modeWord,
318
+ PANE_GROW,
319
+ paneColumnWidth,
294
320
  sizeOf,
295
321
  versionTrail,
296
322
  VERSION,
@@ -1,7 +1,15 @@
1
- <section class="help" when="open" role="dialog" aria-modal="true" aria-label="Key reference" tabindex="-1">
2
- <div class="help-dialog">
1
+ <section class="help" when="open" role="dialog" aria-modal="true" aria-label="Key reference" tabindex="-1" on:click="!dialog:help-cancel">
2
+ <div class="help-dialog" on:click="dialogStop">
3
3
  <header out="title"></header>
4
- <pre out="text"></pre>
5
- <footer out="hint"></footer>
4
+ <div class="help-columns">
5
+ <div class="help-column" out="leftRows"></div>
6
+ <div class="help-column" out="rightRows"></div>
7
+ </div>
8
+ <footer class="help-footer">
9
+ <span class="help-bracket" out="hintOpen"></span>
10
+ <span class="help-title" out="hintHead"></span>
11
+ <span class="help-key" out="hintKeys"></span>
12
+ <span class="help-bracket" out="hintClose"></span>
13
+ </footer>
6
14
  </div>
7
15
  </section>