@ssebastien/tcmd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +7 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. package/src/js/tcmd/web/theme.js +23 -0
@@ -0,0 +1,104 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/tui
7
+ // Terminal UI entry point: builds the session from the core, wires the app
8
+ // component and hands it to the select.tui/OpenTUI renderer. The host adapter
9
+ // releases the terminal around `$EDITOR`/shell commands and restores it when
10
+ // they exit. This layer also wires the JSON-file bookmark store (the core
11
+ // stays free of Node imports).
12
+
13
+ import nodeOs from "node:os"
14
+ import nodePath from "node:path"
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
16
+ import { cell } from "select/state/cells"
17
+ import { render } from "select.tui"
18
+ import { commandRow, getPaletteCommands } from "../core/commands.js"
19
+ import { createBookmarks, createFileBookmarkStore } from "../core/bookmarks.js"
20
+ import { createNodeFs, createNodeOs, createPane, createSession, load } from "../core/index.js"
21
+ import { createAppComponent } from "./app.js"
22
+
23
+ // The TUI palette lists meaningful actions; raw cursor movement is already on
24
+ // the arrow keys and is noise in the list. Hiding it here (rather than in core)
25
+ // keeps the web palette and the shared registry unchanged.
26
+ const PALETTE_NOISE = /^nav\./
27
+
28
+ // Function: tuiPaletteCommands
29
+ // The command rows the TUI palette offers.
30
+ function tuiPaletteCommands() {
31
+ return getPaletteCommands()
32
+ .filter((command) => !PALETTE_NOISE.test(command.id))
33
+ .map(commandRow)
34
+ }
35
+
36
+ // Function: readSize
37
+ // Normalizes a renderer resize payload. OpenTUI's live renderer emits
38
+ // `(width, height)` as two arguments; the test renderer emits a bare width;
39
+ // some wrappers emit a `{ width, height }` / `{ columns, rows }` object.
40
+ function readSize(payload, height) {
41
+ if (typeof payload === "number") return { width: payload, height }
42
+ if (payload && typeof payload === "object") {
43
+ return {
44
+ width: payload.width ?? payload.columns,
45
+ height: payload.height ?? payload.rows,
46
+ }
47
+ }
48
+ return height === undefined ? {} : { height }
49
+ }
50
+
51
+ // Function: bookmarkFile
52
+ // The JSON file the TUI persists bookmarks to:
53
+ // `$TCMD_BOOKMARKS_FILE` or `$XDG_CONFIG_HOME/tcmd/bookmarks.json` (default
54
+ // `~/.config/tcmd/bookmarks.json`).
55
+ function bookmarkFile() {
56
+ if (process.env.TCMD_BOOKMARKS_FILE) return process.env.TCMD_BOOKMARKS_FILE
57
+ const configHome = process.env.XDG_CONFIG_HOME || nodePath.join(nodeOs.homedir(), ".config")
58
+ return nodePath.join(configHome, "tcmd", "bookmarks.json")
59
+ }
60
+
61
+ // Function: main
62
+ // Runs the TUI. `paths` are optional initial directories [left, right].
63
+ async function main(paths = []) {
64
+ const fs = createNodeFs()
65
+ const left = createPane({ fs, path: paths[0] || fs.cwd() })
66
+ const right = createPane({ fs, path: paths[1] || fs.home() })
67
+ let instance = null
68
+ const os = createNodeOs({
69
+ suspend: () => instance?._ownedRenderer?.suspend?.(),
70
+ resume: () => instance?._ownedRenderer?.resume?.(),
71
+ })
72
+ const bookmarks = createBookmarks({
73
+ store: createFileBookmarkStore({
74
+ file: bookmarkFile(),
75
+ readFile,
76
+ writeFile,
77
+ mkdir,
78
+ }),
79
+ })
80
+ const session = createSession({ fs, os, left, right, host: nodeOs.hostname(), bookmarks })
81
+ session.paletteCommands.set(tuiPaletteCommands())
82
+ await load(session)
83
+ const size = cell({ width: 0, height: 0 })
84
+ const help = cell(false)
85
+ const App = createAppComponent(session, { size, help })
86
+ instance = await render(App, { useMouse: true, exitOnCtrlC: false })
87
+ const renderer = instance?.ctx ?? instance?.renderer
88
+ const current = renderer?.width || renderer?.height
89
+ ? { width: renderer.width ?? 0, height: renderer.height ?? 0 }
90
+ : { width: 0, height: 0 }
91
+ size.set(current)
92
+ renderer?.on?.("resize", (payload, height) => {
93
+ const next = readSize(payload, height)
94
+ size.set({
95
+ width: next.width ?? size.value.width,
96
+ height: next.height ?? size.value.height,
97
+ })
98
+ })
99
+ return session
100
+ }
101
+
102
+ export { main, readSize, tuiPaletteCommands }
103
+
104
+ // EOF
@@ -0,0 +1,177 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/tui/theme
7
+ // Central place for TUI colors so the look can be retuned in one spot. The
8
+ // palette is resolved once at load from the environment:
9
+ //
10
+ // - `TCMD_THEME=dark|light|mono` forces a variant.
11
+ // - `NO_COLOR` (any value) or `TERM=dumb` falls back to mono.
12
+ // - anything else uses dark.
13
+ //
14
+ // Every token is an OpenTUI color string, or `undefined` to mean "terminal
15
+ // default" (which is what mono uses throughout). Non-color emphasis (markers,
16
+ // borders, bold/underline attributes) carries the state in mono.
17
+ //
18
+ // Token groups (see tui/catalogue.js for the env they feed):
19
+ //
20
+ // - Surfaces: panel/overlay backgrounds and the two selection rows.
21
+ // `cursorBg` marks the active pane's cursor row and
22
+ // `cursorInactiveBg` the inactive pane's, so focus is visible
23
+ // from the cursor alone.
24
+ // - Text: primary, secondary (hints) and heading (titles/breadcrumbs).
25
+ // - Borders: secondary (pane box borders, header rules, active/inactive) and
26
+ // primary (the frame and the modal overlays).
27
+ // - Accent: primary (cursor, active pane label) and secondary (marks).
28
+ // - Semantic: error, warning and tip, each with a background + foreground
29
+ // for filled badges (the command bar status, the confirm dialog).
30
+ // - Content: the file-listing colors (file/link/device/directory).
31
+ // - Brand: the identity-bar gradient endpoints — "Tiny" stays blue, "Cmd"
32
+ // runs gold to saturated orange.
33
+
34
+ // Constant: DARK
35
+ // Night-blue palette with gold accents: deep blue surfaces and borders,
36
+ // gold for focus and primary accents, saturated orange for marks, purple for
37
+ // devices and tips.
38
+ const DARK = {
39
+ // Surfaces
40
+ surfaceBg: "#0d1526",
41
+ overlayBg: "#111b31",
42
+ mutedBg: "#1c2a4d",
43
+ cursorBg: "#274a9e",
44
+ cursorInactiveBg: "#1b2a51",
45
+ selectionBg: "#2b3f78",
46
+ // Text
47
+ textFg: "#e6ecff",
48
+ textSecondaryFg: "#8fa3d9",
49
+ textHeadingFg: "#ffffff",
50
+ // Borders
51
+ borderSecondaryFg: "#33508f",
52
+ borderSecondaryActiveFg: "#f0b429",
53
+ borderPrimaryFg: "#3d5590",
54
+ borderPrimaryActiveFg: "#ffc233",
55
+ // Accent
56
+ accentFg: "#ffc233",
57
+ accentBg: "#4a3a14",
58
+ accentSecondaryFg: "#ff7a1a",
59
+ accentSecondaryBg: "#4a2a0c",
60
+ // Semantics
61
+ errorFg: "#ff4d5e",
62
+ errorBg: "#4a1526",
63
+ warningFg: "#ffb020",
64
+ warningBg: "#47320d",
65
+ tipFg: "#a78bfa",
66
+ tipBg: "#2e2352",
67
+ // Content
68
+ fileFg: "#e6ecff",
69
+ linkFg: "#4fc3ff",
70
+ devFg: "#b48eff",
71
+ dirFg: "#5c8cff",
72
+ // Brand
73
+ brandTinyFrom: "#4088ff",
74
+ brandTinyTo: "#7cd0ff",
75
+ brandCmdFrom: "#ffc233",
76
+ brandCmdTo: "#ff7a1a",
77
+ }
78
+
79
+ // Constant: LIGHT
80
+ // The same palette tuned for light terminal backgrounds: deeper, saturated
81
+ // hues that hold their weight on white — navy structure, dark gold accents,
82
+ // burnt orange marks, violet devices and tips.
83
+ const LIGHT = {
84
+ // Surfaces
85
+ surfaceBg: "#e8ecf8",
86
+ overlayBg: "#e8ecf8",
87
+ mutedBg: "#d3dbf2",
88
+ cursorBg: "#2f56b8",
89
+ cursorInactiveBg: "#c6d1ee",
90
+ selectionBg: "#c9d5f4",
91
+ // Text
92
+ textFg: "#101a33",
93
+ textSecondaryFg: "#495785",
94
+ textHeadingFg: "#0a1130",
95
+ // Borders
96
+ borderSecondaryFg: "#9aabd6",
97
+ borderSecondaryActiveFg: "#926000",
98
+ borderPrimaryFg: "#8c9cc9",
99
+ borderPrimaryActiveFg: "#a06a00",
100
+ // Accent
101
+ accentFg: "#926000",
102
+ accentBg: "#dbe4fb",
103
+ accentSecondaryFg: "#c2410c",
104
+ accentSecondaryBg: "#fbdcc2",
105
+ // Semantics
106
+ errorFg: "#d3234a",
107
+ errorBg: "#fbd4dd",
108
+ warningFg: "#b45309",
109
+ warningBg: "#fce9c8",
110
+ tipFg: "#6d28d9",
111
+ tipBg: "#e6ddfb",
112
+ // Content
113
+ fileFg: "#101a33",
114
+ linkFg: "#0369a1",
115
+ devFg: "#7c3aed",
116
+ dirFg: "#1d4ed8",
117
+ // Brand
118
+ brandTinyFrom: "#1d4ed8",
119
+ brandTinyTo: "#3b82f6",
120
+ brandCmdFrom: "#a16207",
121
+ brandCmdTo: "#ea580c",
122
+ }
123
+
124
+ // Constant: MONO
125
+ // No color at all: the terminal's own foreground/background. State is carried
126
+ // by markers (`>`, `*`, `[`, `]`, `▸`) and the selected-row background.
127
+ const MONO = {
128
+ surfaceBg: undefined,
129
+ overlayBg: undefined,
130
+ mutedBg: undefined,
131
+ cursorBg: undefined,
132
+ cursorInactiveBg: undefined,
133
+ selectionBg: undefined,
134
+ textFg: undefined,
135
+ textSecondaryFg: undefined,
136
+ textHeadingFg: undefined,
137
+ borderSecondaryFg: undefined,
138
+ borderSecondaryActiveFg: undefined,
139
+ borderPrimaryFg: undefined,
140
+ borderPrimaryActiveFg: undefined,
141
+ accentFg: undefined,
142
+ accentBg: undefined,
143
+ accentSecondaryFg: undefined,
144
+ accentSecondaryBg: undefined,
145
+ errorFg: undefined,
146
+ errorBg: undefined,
147
+ warningFg: undefined,
148
+ warningBg: undefined,
149
+ tipFg: undefined,
150
+ tipBg: undefined,
151
+ fileFg: undefined,
152
+ linkFg: undefined,
153
+ devFg: undefined,
154
+ dirFg: undefined,
155
+ brandTinyFrom: undefined,
156
+ brandTinyTo: undefined,
157
+ brandCmdFrom: undefined,
158
+ brandCmdTo: undefined,
159
+ }
160
+
161
+ // Function: resolveTheme
162
+ // Picks the palette for `environment` (defaults to `process.env`).
163
+ function resolveTheme(environment = globalThis.process?.env ?? {}) {
164
+ const name = environment.TCMD_THEME
165
+ if (name === "light") return LIGHT
166
+ if (name === "dark") return DARK
167
+ if (name === "mono") return MONO
168
+ if (environment.NO_COLOR !== undefined && environment.NO_COLOR !== "") return MONO
169
+ if (environment.TERM === "dumb") return MONO
170
+ return DARK
171
+ }
172
+
173
+ const tuiTheme = resolveTheme()
174
+
175
+ export { DARK, LIGHT, MONO, resolveTheme, tuiTheme }
176
+
177
+ // EOF
@@ -0,0 +1,243 @@
1
+ // Project: TinyCommand
2
+ // Vendored dependency
3
+ //
4
+ // Module: tcmd/vendor/fzy
5
+ // fzy.js — a JavaScript port of fzy's fuzzy finder scoring algorithm.
6
+ //
7
+ // Source: https://github.com/jhawthorn/fzy.js (index.js), version 0.4.1
8
+ // Author: John Hawthorn
9
+ // License: MIT
10
+ //
11
+ // Vendored verbatim (only this header was added) so TinyCommand's pane filter
12
+ // can score fuzzy matches without adding a runtime dependency. `score` and
13
+ // `positions` require a matching needle/haystack: gate them with `hasMatch`.
14
+ //
15
+ // MIT License
16
+ //
17
+ // Copyright (c) 2014 John Hawthorn
18
+ //
19
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
20
+ // of this software and associated documentation files (the "Software"), to deal
21
+ // in the Software without restriction, including without limitation the rights
22
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
+ // copies of the Software, and to permit persons to whom the Software is
24
+ // furnished to do so, subject to the following conditions:
25
+ //
26
+ // The above copyright notice and this permission notice shall be included in
27
+ // all copies or substantial portions of the Software.
28
+ //
29
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
35
+ // THE SOFTWARE.
36
+
37
+ var SCORE_MIN = -Infinity;
38
+ var SCORE_MAX = Infinity;
39
+
40
+ var SCORE_GAP_LEADING = -0.005
41
+ var SCORE_GAP_TRAILING = -0.005
42
+ var SCORE_GAP_INNER = -0.01
43
+ var SCORE_MATCH_CONSECUTIVE = 1.0
44
+ var SCORE_MATCH_SLASH = 0.9
45
+ var SCORE_MATCH_WORD = 0.8
46
+ var SCORE_MATCH_CAPITAL = 0.7
47
+ var SCORE_MATCH_DOT = 0.6
48
+
49
+ function islower(s) {
50
+ return s.toLowerCase() === s;
51
+ }
52
+
53
+ function isupper(s) {
54
+ return s.toUpperCase() === s;
55
+ }
56
+
57
+ function precompute_bonus(haystack) {
58
+ /* Which positions are beginning of words */
59
+ var m = haystack.length;
60
+ var match_bonus = new Array(m);
61
+
62
+ var last_ch = '/';
63
+ for (var i = 0; i < m; i++) {
64
+ var ch = haystack[i];
65
+
66
+ if (last_ch === '/') {
67
+ match_bonus[i] = SCORE_MATCH_SLASH;
68
+ } else if (last_ch === '-' || last_ch === '_' || last_ch === ' ') {
69
+ match_bonus[i] = SCORE_MATCH_WORD;
70
+ } else if (last_ch === '.') {
71
+ match_bonus[i] = SCORE_MATCH_DOT;
72
+ } else if (islower(last_ch) && isupper(ch)) {
73
+ match_bonus[i] = SCORE_MATCH_CAPITAL;
74
+ } else {
75
+ match_bonus[i] = 0;
76
+ }
77
+
78
+ last_ch = ch;
79
+ }
80
+
81
+ return match_bonus;
82
+ }
83
+
84
+ function compute(needle, haystack, D, M) {
85
+ var n = needle.length;
86
+ var m = haystack.length;
87
+
88
+ var lower_needle = needle.toLowerCase();
89
+ var lower_haystack = haystack.toLowerCase();
90
+
91
+ var match_bonus = precompute_bonus(haystack, match_bonus);
92
+
93
+ /*
94
+ * D[][] Stores the best score for this position ending with a match.
95
+ * M[][] Stores the best possible score at this position.
96
+ */
97
+
98
+ for (var i = 0; i < n; i++) {
99
+ D[i] = new Array(m);
100
+ M[i] = new Array(m);
101
+
102
+ var prev_score = SCORE_MIN;
103
+ var gap_score = i === n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
104
+
105
+ for (var j = 0; j < m; j++) {
106
+ if (lower_needle[i] === lower_haystack[j]) {
107
+ var score = SCORE_MIN;
108
+ if (!i) {
109
+ score = (j * SCORE_GAP_LEADING) + match_bonus[j];
110
+ } else if (j) { /* i > 0 && j > 0*/
111
+ score = Math.max(
112
+ M[i - 1][j - 1] + match_bonus[j],
113
+
114
+ /* consecutive match, doesn't stack with match_bonus */
115
+ D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE);
116
+ }
117
+ D[i][j] = score;
118
+ M[i][j] = prev_score = Math.max(score, prev_score + gap_score);
119
+ } else {
120
+ D[i][j] = SCORE_MIN;
121
+ M[i][j] = prev_score = prev_score + gap_score;
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ function score(needle, haystack) {
128
+ var n = needle.length;
129
+ var m = haystack.length;
130
+
131
+ if (!n || !m)
132
+ return SCORE_MIN;
133
+
134
+ if (n === m) {
135
+ /* Since this method can only be called with a haystack which
136
+ * matches needle. If the lengths of the strings are equal the
137
+ * strings themselves must also be equal (ignoring case).
138
+ */
139
+ return SCORE_MAX;
140
+ }
141
+
142
+ if (m > 1024) {
143
+ /*
144
+ * Unreasonably large candidate: return no score
145
+ * If it is a valid match it will still be returned, it will
146
+ * just be ranked below any reasonably sized candidates
147
+ */
148
+ return SCORE_MIN;
149
+ }
150
+
151
+ var D = new Array(n);
152
+ var M = new Array(n);
153
+
154
+ compute(needle, haystack, D, M)
155
+
156
+ return M[n - 1][m - 1];
157
+ }
158
+
159
+ function positions(needle, haystack) {
160
+ var n = needle.length;
161
+ var m = haystack.length;
162
+
163
+ var positions = new Array(n);
164
+
165
+ if (!n || !m)
166
+ return positions;
167
+
168
+ if (n === m) {
169
+ for (var i = 0; i < n; i++)
170
+ positions[i] = i;
171
+ return positions;
172
+ }
173
+
174
+ if (m > 1024) {
175
+ return positions;
176
+ }
177
+
178
+ var D = new Array(n);
179
+ var M = new Array(n);
180
+
181
+ compute(needle, haystack, D, M)
182
+
183
+ /* backtrack to find the positions of optimal matching */
184
+ var match_required = false;
185
+
186
+ for (var i = n - 1, j = m - 1; i >= 0; i--) {
187
+ for (; j >= 0; j--) {
188
+ /*
189
+ * There may be multiple paths which result in
190
+ * the optimal weight.
191
+ *
192
+ * For simplicity, we will pick the first one
193
+ * we encounter, the latest in the candidate
194
+ * string.
195
+ */
196
+ if (D[i][j] !== SCORE_MIN &&
197
+ (match_required || D[i][j] === M[i][j])) {
198
+ /* If this score was determined using
199
+ * SCORE_MATCH_CONSECUTIVE, the
200
+ * previous character MUST be a match
201
+ */
202
+ match_required =
203
+ i && j &&
204
+ M[i][j] === D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
205
+ positions[i] = j--;
206
+ break;
207
+ }
208
+ }
209
+ }
210
+
211
+ return positions;
212
+ }
213
+
214
+ function hasMatch(needle, haystack) {
215
+ needle = needle.toLowerCase()
216
+ haystack = haystack.toLowerCase()
217
+ var l = needle.length
218
+ for (var i = 0, j = 0; i < l; i += 1) {
219
+ j = haystack.indexOf(needle[i], j) + 1
220
+ if (j === 0) return false
221
+ }
222
+ return true
223
+ }
224
+
225
+ export {
226
+ /* constants */
227
+ SCORE_MIN,
228
+ SCORE_MAX,
229
+
230
+ SCORE_GAP_LEADING,
231
+ SCORE_GAP_TRAILING,
232
+ SCORE_GAP_INNER,
233
+ SCORE_MATCH_CONSECUTIVE,
234
+ SCORE_MATCH_SLASH,
235
+ SCORE_MATCH_WORD,
236
+ SCORE_MATCH_CAPITAL,
237
+ SCORE_MATCH_DOT,
238
+
239
+ /* functions */
240
+ score,
241
+ positions,
242
+ hasMatch
243
+ }
@@ -0,0 +1,154 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/web/app
7
+ // Binds the shared App controller (`components/App.js`) to the web layout
8
+ // (`components/App.html`) and to the select.js `ui` runtime. Mirrors
9
+ // `tcmd/tui/app.js` for the DOM backend.
10
+
11
+ import { ui } from "select/ui"
12
+ import { unwrap } from "select/state/cells"
13
+ import appHtml from "../components/App.html" with { type: "text" }
14
+ import { appBehaviors, appEvents, appInit } from "../components/index.js"
15
+ import { registerWebComponents } from "./catalogue.js"
16
+ import { webTheme } from "./theme.js"
17
+
18
+ // Map DOM key names to the core keymap chord names.
19
+ const KEY_NAMES = {
20
+ " ": "space",
21
+ arrowdown: "down",
22
+ arrowleft: "left",
23
+ arrowright: "right",
24
+ arrowup: "up",
25
+ enter: "return",
26
+ }
27
+
28
+ function normalizeKey(event) {
29
+ const raw = `${event?.key ?? ""}`.toLowerCase()
30
+ const name = KEY_NAMES[raw] ?? raw
31
+ // Terminals deliver shifted punctuation (`:`, `!`) as the character
32
+ // itself, so the keymap matches those without the shift modifier; named
33
+ // keys (Tab, arrows) and letters keep their shift state.
34
+ const isPunctuation = name.length === 1 && !/[a-z0-9]/.test(name)
35
+ const shift = Boolean(event?.shiftKey) && !isPunctuation
36
+ return {
37
+ name,
38
+ ctrl: Boolean(event?.ctrlKey),
39
+ meta: Boolean(event?.metaKey),
40
+ shift,
41
+ }
42
+ }
43
+
44
+ // Web-only view wiring for App: forwards root data into child components.
45
+ // Child data keeps the session cells (as `in:` slots do in the TUI), so the
46
+ // child instances subscribe and update granularly; behaviors read cells with
47
+ // `unwrap` at the point of use.
48
+ function webViewBehaviors() {
49
+ return {
50
+ headerView: (_self, data) => ({ host: data.appHost, path: data.appPath }),
51
+ bookmarkView: (_self, data) => ({
52
+ items: data.bookmarkItems,
53
+ mode: data.bookmarkMode,
54
+ open: data.bookmarkOpen,
55
+ query: data.bookmarkQuery,
56
+ selected: data.bookmarkIndex,
57
+ target: data.bookmarkTarget,
58
+ }),
59
+ commandBarView: (_self, data) => ({
60
+ items: data.commandBarItems,
61
+ leftError: data.leftError,
62
+ notice: data.notice,
63
+ rightError: data.rightError,
64
+ }),
65
+ confirmView: (_self, data) => ({ confirm: data.confirm }),
66
+ leftCrumbsView: (_self, data) => ({
67
+ segments: data.leftSegments,
68
+ paneId: data.leftId,
69
+ }),
70
+ leftDetailView: (_self, data) => ({ entry: data.leftEntry, marked: data.leftMarked }),
71
+ leftFilterView: (_self, data) => ({
72
+ query: data.leftFilter,
73
+ paneId: data.leftId,
74
+ filtering: data.filtering,
75
+ entries: data.leftEntries,
76
+ }),
77
+ leftLabelView: (_self, data) => ({ paneId: data.leftId, active: data.active }),
78
+ leftListView: (_self, data) => ({
79
+ entries: data.leftEntries,
80
+ cursor: data.leftCursor,
81
+ marked: data.leftMarked,
82
+ paneId: data.leftId,
83
+ }),
84
+ leftPaneClass: (_self, data) => (unwrap(data.active) === "left" ? webTheme.activeClass : null),
85
+ leftSummaryView: (_self, data) => ({
86
+ entries: data.leftEntries,
87
+ disk: data.leftDisk,
88
+ }),
89
+ paletteView: (_self, data) => ({
90
+ // Cells pass through (as `in:` slots do in the TUI); `query` is written
91
+ // back by the input's `inout` two-way binding.
92
+ items: data.paletteItems,
93
+ mode: data.paletteMode,
94
+ open: data.paletteOpen,
95
+ query: data.paletteQuery,
96
+ selected: data.paletteIndex,
97
+ }),
98
+ previewView: (_self, data) => ({
99
+ preview: data.preview,
100
+ show: data.showPreview,
101
+ }),
102
+ rightCrumbsView: (_self, data) => ({
103
+ segments: data.rightSegments,
104
+ paneId: data.rightId,
105
+ }),
106
+ rightDetailView: (_self, data) => ({ entry: data.rightEntry, marked: data.rightMarked }),
107
+ rightFilterView: (_self, data) => ({
108
+ query: data.rightFilter,
109
+ paneId: data.rightId,
110
+ filtering: data.filtering,
111
+ entries: data.rightEntries,
112
+ }),
113
+ rightLabelView: (_self, data) => ({ paneId: data.rightId, active: data.active }),
114
+ rightListView: (_self, data) => ({
115
+ entries: data.rightEntries,
116
+ cursor: data.rightCursor,
117
+ marked: data.rightMarked,
118
+ paneId: data.rightId,
119
+ }),
120
+ rightPaneClass: (_self, data) => (unwrap(data.active) === "right" ? webTheme.activeClass : null),
121
+ rightSummaryView: (_self, data) => ({
122
+ entries: data.rightEntries,
123
+ disk: data.rightDisk,
124
+ }),
125
+ }
126
+ }
127
+
128
+ // Function: createWebApp
129
+ // Builds the root App component for `session` on the web presentation.
130
+ function createWebApp(session) {
131
+ registerWebComponents()
132
+ // The command panel is a TUI-only view; disable it so no web dispatch path
133
+ // (key, palette, command bar) opens an invisible modal, and drop its
134
+ // palette entries (`:` and `m` both fall back to the script palette).
135
+ session.panelEnabled?.set(false)
136
+ const palette = session.paletteCommands?.value
137
+ if (palette) {
138
+ session.paletteCommands.set(palette.filter((row) => row.id !== "panel.toggle" && row.id !== "panel.open"))
139
+ }
140
+ return ui(appHtml)
141
+ .init(appInit(session))
142
+ .does({
143
+ ...appBehaviors({
144
+ normalizeKey,
145
+ quit: (self) => self.unmount?.(),
146
+ }),
147
+ ...webViewBehaviors(),
148
+ })
149
+ .sub(appEvents({ quit: (self) => self.unmount?.() }))
150
+ }
151
+
152
+ export { createWebApp, normalizeKey }
153
+
154
+ // EOF