@ssebastien/tcmd 0.1.3 → 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 (134) hide show
  1. package/README.md +256 -62
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/desktop/dist/index.html +13 -0
  5. package/desktop/dist/main.js +20298 -0
  6. package/desktop/dist/style.css +1515 -0
  7. package/desktop/frontend/index.html +13 -0
  8. package/package.json +19 -6
  9. package/public/fonts/OFL.txt +93 -0
  10. package/public/index.html +14 -0
  11. package/public/licenses/LUCIDE.txt +43 -0
  12. package/public/main.js +20679 -0
  13. package/public/style.css +1515 -0
  14. package/scripts/build-desktop.js +31 -0
  15. package/scripts/serve-web.js +61 -0
  16. package/src/js/tcmd/components/App.html +80 -14
  17. package/src/js/tcmd/components/App.js +339 -114
  18. package/src/js/tcmd/components/App.xml +76 -22
  19. package/src/js/tcmd/components/BookmarkList.html +5 -5
  20. package/src/js/tcmd/components/BookmarkList.js +15 -41
  21. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  22. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  23. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  24. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  25. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  26. package/src/js/tcmd/components/CommandArgRow.html +28 -0
  27. package/src/js/tcmd/components/CommandArgRow.js +146 -30
  28. package/src/js/tcmd/components/CommandArgRow.xml +55 -4
  29. package/src/js/tcmd/components/CommandBar.html +2 -2
  30. package/src/js/tcmd/components/CommandBar.js +33 -27
  31. package/src/js/tcmd/components/CommandBar.xml +2 -2
  32. package/src/js/tcmd/components/CommandItem.html +1 -1
  33. package/src/js/tcmd/components/CommandItem.js +25 -25
  34. package/src/js/tcmd/components/CommandItem.xml +2 -2
  35. package/src/js/tcmd/components/CommandPanel.html +29 -0
  36. package/src/js/tcmd/components/CommandPanel.js +78 -52
  37. package/src/js/tcmd/components/CommandPanel.xml +49 -25
  38. package/src/js/tcmd/components/Confirm.html +2 -2
  39. package/src/js/tcmd/components/EntryDetail.html +7 -7
  40. package/src/js/tcmd/components/EntryDetail.js +22 -8
  41. package/src/js/tcmd/components/FileList.html +5 -1
  42. package/src/js/tcmd/components/FileList.js +2 -1
  43. package/src/js/tcmd/components/FilePicker.html +10 -0
  44. package/src/js/tcmd/components/FilePicker.js +43 -0
  45. package/src/js/tcmd/components/FilePicker.xml +13 -0
  46. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  47. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  48. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  49. package/src/js/tcmd/components/FilePreview.html +14 -4
  50. package/src/js/tcmd/components/FilePreview.js +111 -12
  51. package/src/js/tcmd/components/FilePreview.xml +28 -8
  52. package/src/js/tcmd/components/FileRow.html +2 -1
  53. package/src/js/tcmd/components/FileRow.js +70 -12
  54. package/src/js/tcmd/components/FilterBar.html +1 -1
  55. package/src/js/tcmd/components/FilterBar.js +14 -4
  56. package/src/js/tcmd/components/FilterBar.xml +1 -1
  57. package/src/js/tcmd/components/Frame.js +324 -0
  58. package/src/js/tcmd/components/Help.html +15 -0
  59. package/src/js/tcmd/components/Help.js +260 -0
  60. package/src/js/tcmd/components/Help.xml +13 -4
  61. package/src/js/tcmd/components/HelpRow.html +8 -0
  62. package/src/js/tcmd/components/HelpRow.xml +7 -0
  63. package/src/js/tcmd/components/OverlayList.html +8 -0
  64. package/src/js/tcmd/components/OverlayList.js +69 -0
  65. package/src/js/tcmd/components/{CommandPalette.xml → OverlayList.xml} +2 -5
  66. package/src/js/tcmd/components/OverlayRow.html +5 -0
  67. package/src/js/tcmd/components/OverlayRow.js +47 -0
  68. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  69. package/src/js/tcmd/components/Summary.html +1 -1
  70. package/src/js/tcmd/components/Summary.js +1 -6
  71. package/src/js/tcmd/components/format.js +4 -23
  72. package/src/js/tcmd/components/icons.js +176 -5
  73. package/src/js/tcmd/components/index.js +9 -5
  74. package/src/js/tcmd/components/rows.js +176 -48
  75. package/src/js/tcmd/core/base64.js +64 -0
  76. package/src/js/tcmd/core/bookmarks.js +45 -8
  77. package/src/js/tcmd/core/commands.js +978 -260
  78. package/src/js/tcmd/core/concurrency.js +59 -0
  79. package/src/js/tcmd/core/decompress.js +117 -0
  80. package/src/js/tcmd/core/directory.js +219 -93
  81. package/src/js/tcmd/core/format.js +36 -0
  82. package/src/js/tcmd/core/fs.js +438 -54
  83. package/src/js/tcmd/core/history.js +58 -0
  84. package/src/js/tcmd/core/index.js +3 -9
  85. package/src/js/tcmd/core/journal.js +93 -0
  86. package/src/js/tcmd/core/keymap.js +42 -6
  87. package/src/js/tcmd/core/operations.js +813 -48
  88. package/src/js/tcmd/core/os.js +29 -3
  89. package/src/js/tcmd/core/pane.js +33 -8
  90. package/src/js/tcmd/core/panel.js +280 -104
  91. package/src/js/tcmd/core/path.js +30 -3
  92. package/src/js/tcmd/core/permissions.js +107 -0
  93. package/src/js/tcmd/core/picker.js +156 -0
  94. package/src/js/tcmd/core/preview.js +102 -0
  95. package/src/js/tcmd/core/script.js +7 -95
  96. package/src/js/tcmd/core/selection.js +9 -1
  97. package/src/js/tcmd/core/session.js +498 -200
  98. package/src/js/tcmd/core/watch.js +153 -0
  99. package/src/js/tcmd/desktop/backend/api.js +165 -0
  100. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  101. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  102. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  103. package/src/js/tcmd/desktop/fs.js +91 -0
  104. package/src/js/tcmd/desktop/main.js +194 -0
  105. package/src/js/tcmd/desktop/os.js +33 -0
  106. package/src/js/tcmd/index.js +0 -1
  107. package/src/js/tcmd/tui/app.js +118 -60
  108. package/src/js/tcmd/tui/catalogue.js +238 -42
  109. package/src/js/tcmd/tui/index.js +18 -19
  110. package/src/js/tcmd/tui/opentui.js +33 -0
  111. package/src/js/tcmd/tui/syntax.js +91 -0
  112. package/src/js/tcmd/tui/theme.js +32 -2
  113. package/src/js/tcmd/vendor/bzip2.js +276 -0
  114. package/src/js/tcmd/web/app.js +225 -41
  115. package/src/js/tcmd/web/bookmarks.js +36 -0
  116. package/src/js/tcmd/web/catalogue.js +81 -38
  117. package/src/js/tcmd/web/effects.js +223 -0
  118. package/src/js/tcmd/web/header.html +1 -1
  119. package/src/js/tcmd/web/header.js +15 -11
  120. package/src/js/tcmd/web/http.js +13 -0
  121. package/src/js/tcmd/web/index.js +2 -2
  122. package/src/js/tcmd/web/main.js +6 -66
  123. package/src/js/tcmd/web/opfs.js +297 -30
  124. package/src/js/tcmd/web/seed.js +4 -4
  125. package/src/js/tcmd/web/server.js +124 -21
  126. package/src/js/tcmd/web/theme.js +4 -0
  127. package/tinyjs.json +16 -0
  128. package/src/js/tcmd/components/CommandPalette.html +0 -7
  129. package/src/js/tcmd/components/CommandPalette.js +0 -58
  130. package/src/js/tcmd/components/PaletteRow.html +0 -3
  131. package/src/js/tcmd/components/PaletteRow.js +0 -43
  132. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  133. package/src/js/tcmd/tui/frame.js +0 -269
  134. package/src/js/tcmd/tui/help.js +0 -32
@@ -8,10 +8,20 @@
8
8
  // their leading content; other kinds show a metadata card. Renderer-agnostic
9
9
  // logic; `FilePreview.xml` / `FilePreview.html` are the presentations.
10
10
  //
11
- // Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`.
11
+ // A directory is the exception: it renders the *directory column* — its own
12
+ // breadcrumbs over a cursorable listing — so a previewed directory looks and
13
+ // behaves like a pane in every view mode (click a row to focus it, Enter to
14
+ // descend). The listing is the inspector pane's (`session.panes.preview`),
15
+ // threaded down as the `dir*` inputs; `paneId` is what rows, crumbs and the
16
+ // column body publish back to the App.
17
+ //
18
+ // Environment: `textFg`, `textSecondaryFg`, `textHeadingFg`, and the optional
19
+ // `actions` footer text (default: the TUI's `[Open O] [Edit E]` hints; the web
20
+ // substitutes a note that those keys do not exist in a browser).
12
21
 
13
22
  import { unwrap } from "select/state/cells"
14
23
  import { formatSize } from "./format.js"
24
+ import { previewSource } from "../core/preview.js"
15
25
 
16
26
  const KIND_LABELS = {
17
27
  directory: "directory",
@@ -48,16 +58,20 @@ function hasText(preview) {
48
58
 
49
59
  // Function: previewTitle
50
60
  // "Filename <EXT>" style title; falls back to the kind label when the file has
51
- // no extension.
61
+ // no extension. A decompressed preview adds the inner label, so
62
+ // `notes.txt.gz` reads `notes.txt.gz <GZ · TXT>`.
52
63
  function previewTitle(preview) {
53
64
  if (!preview) return "preview"
54
65
  const badge = (preview.ext || KIND_LABELS[preview.kind] || "file").toUpperCase()
55
- return `${preview.title} <${badge}>`
66
+ const inner = preview.compression && preview.innerExt ? ` · ${preview.innerExt.toUpperCase()}` : ""
67
+ return `${preview.title} <${badge}${inner}>`
56
68
  }
57
69
 
58
70
  // Function: entryCard
59
71
  // Metadata card for the entry the session did not preview: a directory, or a
60
- // file whose kind has no inline preview.
72
+ // file whose kind has no inline preview. Directories normally take the
73
+ // directory column instead (see `isDirectoryPane`); the branch stays for a
74
+ // `stat` payload and for hosts that render no listing.
61
75
  function entryCard(entry) {
62
76
  if (!entry) return ""
63
77
  const lines = []
@@ -69,42 +83,127 @@ function entryCard(entry) {
69
83
  return lines.join("\n")
70
84
  }
71
85
 
86
+ // Function: isDirectoryPane
87
+ // True when the inspector previews a plain directory: no `stat` card was
88
+ // pinned on it, so the directory column takes the body.
89
+ function isDirectoryPane(preview, entry) {
90
+ return Boolean(entry?.isDir) && !preview
91
+ }
92
+
72
93
  // Function: headerTitle
73
- // The inspector title: the preview's own title, else the cursor entry.
94
+ // The inspector title: the preview's own title, else the cursor entry — whose
95
+ // symlink target and resolved extension `previewSource` supplies, so the
96
+ // header names the traversal before the async read lands.
74
97
  function headerTitle(preview, entry) {
75
98
  if (preview) return previewTitle(preview)
76
99
  if (entry) {
77
- const badge = entry.isDir ? "DIR" : (entry.ext || "file").toUpperCase()
78
- return `${entry.name}${entry.isDir ? "/" : ""} <${badge}>`
100
+ const source = previewSource(entry)
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
+ : ""
106
+ const name = entry.isDir ? `${entry.name}/` : entry.name
107
+ const target = entry.isSymlink && entry.target ? ` -> ${entry.target}` : ""
108
+ return `${name}${target} <${badge}${inner}>`
79
109
  }
80
110
  return "inspector"
81
111
  }
82
112
 
83
113
  // Function: createBehaviors
84
114
  // Builds the FilePreview behavior map for the given renderer environment.
115
+ // `env.actions` overrides the footer line so a presentation can carry its own
116
+ // copy (the browser has no Open/Edit); the default advertises the TUI keys.
85
117
  function createBehaviors(env) {
118
+ const actions = env.actions ?? (() => " [Open O] [Edit E]")
119
+ const directory = (_self, data) =>
120
+ isDirectoryPane(unwrap(data.preview), unwrap(data.entry))
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
+ }
86
134
  return {
87
- actions: () => " [Open O] [Edit E]",
135
+ actions,
88
136
  actionsFg: () => env.textSecondaryFg,
89
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
+ },
148
+ // The directory column: breadcrumbs over the inspector pane's listing,
149
+ // shaped like a pane header (a rule separates it from the body).
150
+ dirCrumbsView: (_self, data) => ({ segments: data.dirSegments, paneId: data.paneId }),
151
+ dirListView: (_self, data) => ({
152
+ entries: data.dirEntries,
153
+ cursor: data.dirCursor,
154
+ marked: data.dirMarked,
155
+ active: data.dirActive,
156
+ loading: data.dirLoading,
157
+ paneId: data.paneId,
158
+ }),
90
159
  dimFg: () => env.textSecondaryFg,
91
160
  emptyText: () => " No file selected\n Move the cursor or press v",
92
161
  entryText: (_self, data) => entryCard(unwrap(data.entry)),
162
+ // The rule between the contents and the shortcut footer.
163
+ footRule: () => ["top"],
164
+ footRuleFg: () => env.textSecondaryFg,
165
+ headRule: () => ["top"],
166
+ headRuleFg: () => env.textSecondaryFg,
93
167
  hasCard: (_self, data) => hasCard(unwrap(data.preview)),
94
- // The metadata card fills in whenever there is a cursor entry but no
95
- // preview payload (a directory, or a non-text file).
96
- hasEntryCard: (_self, data) => !unwrap(data.preview) && Boolean(unwrap(data.entry)),
97
168
  hasText: (_self, data) => hasText(unwrap(data.preview)),
98
169
  headerTitle: (_self, data) => headerTitle(unwrap(data.preview), unwrap(data.entry)),
99
170
  isEmpty: (_self, data) => !unwrap(data.preview) && !unwrap(data.entry),
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,
100
176
  previewText: (_self, data) => unwrap(data.preview)?.text ?? "",
101
177
  previewTitle: (_self, data) => previewTitle(unwrap(data.preview)),
102
178
  previewTitleColor: () => env.textHeadingFg,
103
179
  show: (_self, data) => Boolean(unwrap(data.show)),
180
+ // One body at a time: the directory column, else the card/text/empty
181
+ // states, with the title row yielding to the breadcrumb header.
182
+ // `showEntryCard` fills in whenever there is a cursor entry but no
183
+ // preview payload — except a directory, which gets the listing.
184
+ showBody: body,
185
+ showCard: (_self, data) => body(_self, data) && hasCard(unwrap(data.preview)),
186
+ showEmpty: (_self, data) => body(_self, data) && !unwrap(data.preview) && !unwrap(data.entry),
187
+ showEntryCard: (_self, data) =>
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),
192
+ showText: (_self, data) => body(_self, data) && hasText(unwrap(data.preview)),
193
+ showTextOrCard: (_self, data) => body(_self, data) && !isImage(_self, data),
104
194
  textFg: () => env.textFg,
105
195
  }
106
196
  }
107
197
 
108
- export { cardText, createBehaviors, entryCard, hasCard, hasText, headerTitle, previewTitle }
198
+ export {
199
+ cardText,
200
+ createBehaviors,
201
+ entryCard,
202
+ hasCard,
203
+ hasText,
204
+ headerTitle,
205
+ isDirectoryPane,
206
+ previewTitle,
207
+ }
109
208
 
110
209
  // EOF
@@ -1,10 +1,30 @@
1
+ <!-- The inspector: a card/text body for a file, and a directory column
2
+ (breadcrumbs + cursorable listing) for a directory, so a previewed
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. -->
1
7
  <box when="show" out:title="headerTitle" out:titleColor="previewTitleColor" border borderStyle="rounded"
2
- flexDirection="column" flexGrow="1" overflow="hidden">
3
- <scrollbox flexGrow="1">
4
- <text when="hasText" out="previewText" out:fg="textFg"></text>
5
- <text when="hasCard" out="cardText" out:fg="dimFg"></text>
6
- <text when="hasEntryCard" out="entryText" out:fg="dimFg"></text>
7
- <text when="isEmpty" out="emptyText" out:fg="dimFg"></text>
8
- </scrollbox>
9
- <box height="1"><text out="actions" out:fg="actionsFg"></text></box>
8
+ flexDirection="column" flexGrow="1" overflow="hidden" on:click="paneId!pane:activate">
9
+ <box when="isDirPane" flexDirection="row" height="1" paddingLeft="1" overflow="hidden">
10
+ <box flexGrow="1" flexShrink="1" minWidth="0" overflow="hidden">
11
+ <breadcrumbs in:segments="dirSegments" in:paneId="paneId" height="1"></breadcrumbs>
12
+ </box>
13
+ </box>
14
+ <box when="isDirPane" height="1" out:border="headRule" out:borderColor="headRuleFg"></box>
15
+ <filelist when="isDirPane" in:entries="dirEntries" in:cursor="dirCursor" in:marked="dirMarked"
16
+ in:active="dirActive" in:paneId="paneId" in:loading="dirLoading"></filelist>
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>
10
30
  </box>
@@ -1,5 +1,6 @@
1
- <li class="file-row" out:class="rowClass" on:click="paneId:paneId,index:index!list:select">
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>
@@ -8,22 +8,27 @@
8
8
  // markedRow, active, paneId }`, the same bag shape in both engines.
9
9
  // `currentRow`/`markedRow` are plain per-row booleans snapshotted by the
10
10
  // `fileRows` builder (see `rows.js`); `active` is the pane-focus cell, read
11
- // with `unwrap` at the point of use.
11
+ // with `unwrap` at the point of use. A symlink row carries its target in the
12
+ // name (`notes -> ../real/notes`, see `rowName`).
12
13
  //
13
14
  // Environment: `cursorBg`, `cursorInactiveBg`, `currentClass`,
14
- // `accentSecondaryFg`, `accentFg`, `linkFg`, `dirFg`, `devFg`, `fileFg`,
15
- // `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).
16
18
 
17
19
  import { unwrap } from "select/state/cells"
18
20
  import { formatSize } from "./format.js"
19
- import { iconFor } from "./icons.js"
21
+ import { iconFor, lucideIconFor } from "./icons.js"
20
22
 
21
23
  // Function: rowName
22
24
  // The entry name with the directory slash (the icon is its own slot, so it
23
- // can be dimmer than the name — see `rowIcon`).
25
+ // can be dimmer than the name — see `rowIcon`), and, for a symlink, the
26
+ // `-> target` tail `ls -l` prints. The name cell truncates, so a narrow pane
27
+ // drops the target before the name itself.
24
28
  function rowName(entry) {
25
29
  if (!entry) return ""
26
- return entry.isDir ? `${entry.name}/` : entry.name
30
+ const name = entry.isDir ? `${entry.name}/` : entry.name
31
+ return entry.target ? `${name} -> ${entry.target}` : name
27
32
  }
28
33
 
29
34
  // Function: rowIcon
@@ -33,6 +38,12 @@ function rowIcon(entry, icons) {
33
38
  return icons ? iconFor(entry) : ""
34
39
  }
35
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
+
36
47
  // Function: rowSize
37
48
  function rowSize(entry) {
38
49
  if (!entry) return ""
@@ -76,21 +87,64 @@ function rowIsMarked(data) {
76
87
  return isMarked(data.item, unwrap(data.marked))
77
88
  }
78
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
+
79
116
  // Function: nameFg
80
117
  // The listing color for an entry: a symlink reads as a link, a directory as a
81
- // 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.
82
120
  // Symlinks win over directories, matching `dircolors` (a link to a directory
83
- // 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).
84
123
  function nameFg(item, env) {
85
124
  if (item?.isSymlink) return env.linkFg
86
125
  if (item?.isDir) return env.dirFg
87
126
  if (item?.isDevice) return env.devFg
127
+ if (isExecutable(item)) return env.execFg
128
+ if (isReadonly(item)) return env.readonlyFg
88
129
  return env.fileFg
89
130
  }
90
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
+
91
144
  // Function: rowNameFg
92
145
  // The name color for a row: a marked row reads orange (the selection signal),
93
- // 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`).
94
148
  // Marks win over the cursor so a marked-and-current row still reads as
95
149
  // marked, the same precedence the row marker follows.
96
150
  function rowNameFg(data, env) {
@@ -115,21 +169,25 @@ function createBehaviors(env) {
115
169
  return undefined
116
170
  },
117
171
  rowClass: (_self, data) => (rowCurrent(data) ? env.currentClass : null),
172
+ rowMarked: (_self, data) => `${rowIsMarked(data)}`,
173
+ rowActive: (_self, data) => `${unwrap(data.active) !== false}`,
174
+ rowKind: (_self, data) => data.item?.isSymlink ? "link" : data.item?.isDir ? "dir" : data.item?.isDevice ? "dev" : "file",
118
175
  // The icon is its own slot so it can sit dimmer than the name; it
119
176
  // collapses when the environment keeps rows icon-free.
120
- hasIcon: () => Boolean(env.icons),
177
+ hasIcon: () => Boolean(env.icons || env.lucideIcons),
121
178
  rowIcon: (_self, data) => rowIcon(data.item, Boolean(env.icons)),
179
+ rowIconHref: (_self, data) => rowIconHref(data.item, Boolean(env.lucideIcons)),
122
180
  rowIconFg: () => env.textSecondaryFg,
123
181
  rowMarker: (_self, data) => rowMarker(data),
124
182
  rowMarkerFg: (_self, data) => (rowIsMarked(data) ? env.accentSecondaryFg : env.accentFg),
125
183
  rowName: (_self, data) => rowName(data.item),
126
- rowNameClass: (_self, data) => (data.item?.isDir ? env.dirClass : null),
184
+ rowNameClass: (_self, data) => nameClass(data.item, env),
127
185
  rowNameFg: (_self, data) => rowNameFg(data, env),
128
186
  rowSize: (_self, data) => rowSize(data.item),
129
187
  rowSizeFg: () => env.textSecondaryFg,
130
188
  }
131
189
  }
132
190
 
133
- export { createBehaviors, isMarked, nameFg, rowIcon, rowName, rowNameFg, rowSize }
191
+ export { createBehaviors, isExecutable, isMarked, isReadonly, nameClass, nameFg, rowIcon, rowIconHref, rowName, rowNameFg, rowSize }
134
192
 
135
193
  // EOF
@@ -1,6 +1,6 @@
1
1
  <div class="filter-bar" when="visible">
2
2
  <span class="filter-prompt" out="prompt"></span>
3
- <input when="editing" ref="query" inout="query" class="filter-input" />
3
+ <input when="editing" ref="query" inout="query" on:input="onFilter" class="filter-input" />
4
4
  <span when="applied" class="filter-label" out="label"></span>
5
5
  <span class="filter-alert" out="alert"></span>
6
6
  </div>
@@ -7,10 +7,12 @@
7
7
  // FilterBar: the fuzzy filter indicator, folded into the pane header so it
8
8
  // never shifts the listing. While the pane owns the filter input
9
9
  // (`filtering === paneId`) it shows a compact focused input bound to the pane's
10
- // `query`; once accepted it degrades to the same `/ query` indication, so the
11
- // pane is navigable again and the filter can still be cleared with Esc. When
12
- // the query matches no entry it shows a `no match` alert. The key hints live in
13
- // the command bar, not here.
10
+ // `query`; each change also routes through the pane's `setFilter` (passed as
11
+ // `setFilter`), so the cursor re-anchors with no directory watcher. Once
12
+ // accepted it degrades to the same `/ query` indication, so the pane is
13
+ // navigable again and the filter can still be cleared with Esc. When the query
14
+ // matches no entry it shows a `no match` alert. The key hints live in the
15
+ // command bar, not here.
14
16
  //
15
17
  // Environment: `accentFg` (the applied label), `textSecondaryFg` (the `/`
16
18
  // prompt), `warningFg` (the no-match alert).
@@ -33,6 +35,14 @@ function createBehaviors(env) {
33
35
  applied: (self, data) => !editing(self, data) && activeQuery(self, data).length > 0,
34
36
  editing,
35
37
  focused: editing,
38
+ // The input both displays and captures through `inout`; `on:input`
39
+ // additionally routes the change through the pane's `setFilter` so the
40
+ // cursor re-anchors on the first match. The TUI delivers the input
41
+ // value directly, the DOM an event carrying it.
42
+ onFilter: (_self, data, event) => {
43
+ const value = typeof event === "string" ? event : `${event?.target?.value ?? ""}`
44
+ data.setFilter?.(value)
45
+ },
36
46
  // The bare query (shared with the web presentation).
37
47
  label: (self, data) => queryText(self, data),
38
48
  labelFg: () => env.accentFg,
@@ -1,6 +1,6 @@
1
1
  <box when="visible" flexDirection="row" height="1" flexShrink="0">
2
2
  <text out="prompt" out:fg="promptFg"></text>
3
- <input when="editing" ref="query" inout="query" out:focused="focused" width="14" height="1"></input>
3
+ <input when="editing" ref="query" inout="query" on:input="onFilter" out:focused="focused" width="14" height="1"></input>
4
4
  <text when="applied" out="labelText" out:fg="labelFg" wrapMode="none" truncate="true"></text>
5
5
  <text out="alert" out:fg="alertFg" wrapMode="none"></text>
6
6
  </box>