@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,453 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/core/operations
7
+ // The scripting operations: the file-management verbs the command palette's
8
+ // scripting mode completes and runs. An operation receives the session and
9
+ // its raw argument tokens; path arguments resolve filesets ($A … $FILE), `~`
10
+ // and paths relative to the active pane. Operations own their side effects and
11
+ // return a one-line message (shown in the command bar) or undefined.
12
+ //
13
+ // Registry entries carry the presentation metadata the palette renders:
14
+ // `signature`, `description`, `aliases` and an optional `confirmText`. An
15
+ // optional `risk` ("caution" for a move, "destructive" for delete/erase) lets
16
+ // the command panel tint the operation by how dangerous it is.
17
+ //
18
+ // They also carry `args`, the structured signature the command panel renders:
19
+ // one descriptor per positional argument. A descriptor is
20
+ // `{ name, kind, role?, optional?, variadic? }` where `kind` is:
21
+ // - "selection": one or more paths from a pane's selection (marks, falling
22
+ // back to the cursor entry). `role` picks the pane: "source" (SRC), "dest"
23
+ // (DST) or "active" (default).
24
+ // - "dest": a single destination directory (the DST pane's path).
25
+ // - "buffer": a selection-buffer label (`+A`, `-B`, …).
26
+ // - "literal": free text (a name), edited in the panel's text field.
27
+ // - "raw": the rest of the line, unparsed (a shell command).
28
+
29
+ import { basename, extname, join, normalize } from "./path.js"
30
+ import { PANE_BUFFERS } from "./pane.js"
31
+
32
+ // Constant: PATHS_PREFIX
33
+ // The prefix of a self-contained path-list token the command panel encodes
34
+ // (`$paths:["/a","/b"]`), which `expandArg` decodes back to paths. It keeps a
35
+ // panel argument self-contained (no session state), so it still resolves after
36
+ // an async confirm re-runs it.
37
+ const PATHS_PREFIX = "$paths:"
38
+
39
+ // Function: pathToken
40
+ // Encodes a resolved path list as one `$paths:` token.
41
+ function pathToken(paths) {
42
+ return `${PATHS_PREFIX}${JSON.stringify(paths ?? [])}`
43
+ }
44
+
45
+ // Function: decodePathToken
46
+ // Decodes a `$paths:` token to normalized paths, or null when `text` is not one
47
+ // (so `expandArg` can fall through to the named filesets).
48
+ function decodePathToken(text) {
49
+ if (!`${text ?? ""}`.startsWith(PATHS_PREFIX)) return null
50
+ try {
51
+ const parsed = JSON.parse(`${text}`.slice(PATHS_PREFIX.length))
52
+ return Array.isArray(parsed) ? parsed.map((path) => normalize(`${path}`)) : []
53
+ } catch (error) {
54
+ return []
55
+ }
56
+ }
57
+
58
+ const operations = [
59
+ {
60
+ name: "copy",
61
+ signature: "copy FROM TO",
62
+ description: "Copy files and directories",
63
+ arity: { min: 2, max: 2 },
64
+ args: [
65
+ { name: "FROM", kind: "selection", role: "source" },
66
+ { name: "TO", kind: "dest" },
67
+ ],
68
+ async run(session, args) {
69
+ const sources = expandAll(session, [args[0]])
70
+ const targets = expandAll(session, [args[1]])
71
+ if (targets.length !== 1) throw new Error("copy: TO must be a single path")
72
+ const [target] = targets
73
+ if (sources.length > 1 && !(await isDir(session, target))) {
74
+ throw new Error("copy: TO must be an existing directory for multiple FROM")
75
+ }
76
+ const into = await isDir(session, target)
77
+ for (const source of sources) {
78
+ if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
79
+ await session.fs.copy(source, into ? join(target, basename(source)) : target)
80
+ }
81
+ await refreshPanes(session)
82
+ return `copied ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
83
+ },
84
+ },
85
+ {
86
+ name: "move",
87
+ signature: "move FROM TO",
88
+ description: "Move files and directories",
89
+ risk: "caution",
90
+ arity: { min: 2, max: 2 },
91
+ args: [
92
+ { name: "FROM", kind: "selection", role: "source" },
93
+ { name: "TO", kind: "dest" },
94
+ ],
95
+ async run(session, args) {
96
+ const sources = expandAll(session, [args[0]])
97
+ const targets = expandAll(session, [args[1]])
98
+ if (targets.length !== 1) throw new Error("move: TO must be a single path")
99
+ const [target] = targets
100
+ if (sources.length > 1 && !(await isDir(session, target))) {
101
+ throw new Error("move: TO must be an existing directory for multiple FROM")
102
+ }
103
+ const into = await isDir(session, target)
104
+ for (const source of sources) {
105
+ if (!(await session.fs.stat(source))) throw new Error(`not found: ${source}`)
106
+ await session.fs.move(source, into ? join(target, basename(source)) : target)
107
+ }
108
+ await refreshPanes(session)
109
+ return `moved ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
110
+ },
111
+ },
112
+ {
113
+ name: "delete",
114
+ signature: "delete PATH...",
115
+ description: "Delete paths, moving them to the trash",
116
+ risk: "destructive",
117
+ arity: { min: 1 },
118
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
119
+ async run(session, args) {
120
+ const paths = expandAll(session, args)
121
+ for (const path of paths) {
122
+ if (!(await session.fs.stat(path))) throw new Error(`not found: ${path}`)
123
+ }
124
+ await session.fs.trash(paths)
125
+ await refreshPanes(session)
126
+ return `trashed ${paths.length} path${paths.length === 1 ? "" : "s"}`
127
+ },
128
+ },
129
+ {
130
+ name: "erase",
131
+ signature: "erase PATH...",
132
+ description: "Delete paths without moving them to the trash",
133
+ risk: "destructive",
134
+ arity: { min: 1 },
135
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
136
+ confirmText: (args) => `erase ${args.join(" ")} without trash?`,
137
+ async run(session, args) {
138
+ const paths = expandAll(session, args)
139
+ await session.fs.erase(paths)
140
+ await refreshPanes(session)
141
+ return `erased ${paths.length} path${paths.length === 1 ? "" : "s"}`
142
+ },
143
+ },
144
+ {
145
+ name: "stat",
146
+ signature: "stat PATH...",
147
+ description: "Show file information, including disk usage",
148
+ arity: { min: 1 },
149
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
150
+ async run(session, args) {
151
+ const paths = expandAll(session, args)
152
+ const details = []
153
+ for (const path of paths) {
154
+ const stats = await session.fs.stat(path)
155
+ if (!stats) throw new Error(`not found: ${path}`)
156
+ const size = stats.isDir ? await session.fs.du(path) : stats.size
157
+ details.push(path)
158
+ details.push(
159
+ ` ${stats.isDir ? "directory" : "file"} · ${humanSize(size)} · ${new Date(stats.mtimeMs || 0).toISOString()}`,
160
+ )
161
+ }
162
+ session.showPreview.set(true)
163
+ session.previewTarget.set(await previewTarget(session, paths[0], details))
164
+ return `stat ${paths.join(" ")}`
165
+ },
166
+ },
167
+ {
168
+ name: "touch",
169
+ signature: "touch PATH...",
170
+ description: "Create files or bump their modification time",
171
+ arity: { min: 1 },
172
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
173
+ async run(session, args) {
174
+ const paths = expandAll(session, args)
175
+ for (const path of paths) await session.fs.touch(path)
176
+ await refreshPanes(session)
177
+ return `touched ${paths.length} path${paths.length === 1 ? "" : "s"}`
178
+ },
179
+ },
180
+ {
181
+ name: "sel",
182
+ signature: "sel [+|-][BUF] PATH...",
183
+ description: "Add or remove paths from a selection buffer",
184
+ arity: { min: 1 },
185
+ args: [
186
+ { name: "BUF", kind: "buffer", optional: true },
187
+ { name: "PATH", kind: "selection", role: "active", variadic: true },
188
+ ],
189
+ async run(session, args) {
190
+ const { sign, buffer, tokens } = parseSelection(args)
191
+ if (!tokens.length) throw new Error("sel needs at least one path")
192
+ const paths = expandAll(session, tokens)
193
+ const target = findBuffer(session, buffer)
194
+ const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[session.active.value] ?? PANE_BUFFERS.left
195
+ const marks = new Set(target.marked.value)
196
+ for (const path of paths) {
197
+ if (sign === "-") marks.delete(path)
198
+ else marks.add(path)
199
+ }
200
+ target.marked.set(marks)
201
+ const verb = sign === "-" ? "removed" : "added"
202
+ const to = sign === "-" ? "from" : "to"
203
+ return `${verb} ${paths.length} path${paths.length === 1 ? "" : "s"} ${to} $${label}`
204
+ },
205
+ },
206
+ {
207
+ name: "edit",
208
+ signature: "edit PATH",
209
+ description: "Edit a file with $EDITOR",
210
+ arity: { min: 1, max: 1 },
211
+ args: [{ name: "PATH", kind: "selection", role: "active" }],
212
+ async run(session, args) {
213
+ const paths = expandAll(session, args)
214
+ if (paths.length !== 1) throw new Error("edit expects a single path")
215
+ const [path] = paths
216
+ await session.os.edit(path)
217
+ await refreshPanes(session)
218
+ return `edited ${path}`
219
+ },
220
+ },
221
+ {
222
+ name: "view",
223
+ signature: "view PATH",
224
+ description: "Preview a path",
225
+ arity: { min: 1, max: 1 },
226
+ args: [{ name: "PATH", kind: "selection", role: "active" }],
227
+ async run(session, args) {
228
+ const paths = expandAll(session, args)
229
+ if (paths.length !== 1) throw new Error("view expects a single path")
230
+ const [path] = paths
231
+ session.showPreview.set(true)
232
+ session.previewTarget.set(await previewTarget(session, path))
233
+ return `viewing ${path}`
234
+ },
235
+ },
236
+ {
237
+ name: "open",
238
+ signature: "open PATH...",
239
+ description: "Open paths with the desktop opener",
240
+ arity: { min: 1 },
241
+ args: [{ name: "PATH", kind: "selection", role: "active", variadic: true }],
242
+ async run(session, args) {
243
+ const paths = expandAll(session, args)
244
+ for (const path of paths) await session.os.open(path)
245
+ return `opened ${paths.length} path${paths.length === 1 ? "" : "s"}`
246
+ },
247
+ },
248
+ {
249
+ name: "bookmark",
250
+ signature: "bookmark [NAME] [PATH]",
251
+ description: "List, add or remove named bookmarks",
252
+ arity: { min: 0, max: 2 },
253
+ args: [
254
+ { name: "NAME", kind: "literal", optional: true },
255
+ { name: "PATH", kind: "selection", role: "active", optional: true },
256
+ ],
257
+ async run(session, args) {
258
+ const bookmarks = session.bookmarks
259
+ if (args.length === 0) {
260
+ const rows = bookmarks.rows()
261
+ if (!rows.length) return "no bookmarks"
262
+ return `bookmarks:\n${rows
263
+ .map((row) => ` ${row.name} ${row.path}${row.isDir === true ? "/" : ""}`)
264
+ .join("\n")}`
265
+ }
266
+ const [name, path] = args
267
+ if (!path) {
268
+ if (!(await bookmarks.remove(name))) throw new Error(`no such bookmark: ${name}`)
269
+ return `removed bookmark ${name}`
270
+ }
271
+ const target = expandArg(session, path)
272
+ if (target.length !== 1) throw new Error("bookmark: PATH must be a single path")
273
+ const stats = await session.fs.stat(target[0])
274
+ await bookmarks.record(name, target[0], stats ? Boolean(stats.isDir) : null)
275
+ return `bookmarked ${name} → ${target[0]}`
276
+ },
277
+ },
278
+ {
279
+ name: "run",
280
+ signature: "run CMD",
281
+ aliases: ["!"],
282
+ description: "Run a shell command in the active pane",
283
+ raw: true,
284
+ arity: { min: 1 },
285
+ args: [{ name: "CMD", kind: "raw" }],
286
+ async run(session, args) {
287
+ const command = `${args[0] ?? ""}`.trim()
288
+ if (!command) throw new Error("run needs a command")
289
+ const cwd = session.panes[session.active.value]?.path.value
290
+ await session.os.run(command, { cwd })
291
+ await refreshPanes(session)
292
+ return `ran ${command}`
293
+ },
294
+ },
295
+ ]
296
+
297
+ // Function: getOperations
298
+ // The full operation registry.
299
+ function getOperations() {
300
+ return operations
301
+ }
302
+
303
+ // Function: getOperation
304
+ // Looks up an operation by name or alias (case-insensitive).
305
+ function getOperation(name) {
306
+ const needle = `${name ?? ""}`.toLowerCase()
307
+ for (const operation of operations) {
308
+ if (operation.name === needle) return operation
309
+ if ((operation.aliases ?? []).some((alias) => alias.toLowerCase() === needle)) return operation
310
+ }
311
+ return null
312
+ }
313
+
314
+ // Function: runOperation
315
+ // Runs an operation directly with already-split argument tokens, shared by the
316
+ // script line runner and the command panel. `options.confirmed` skips the
317
+ // confirm prompt. Writes the result (or the failure) to the session notice and
318
+ // returns the status: "ran", "confirm", "empty" or "error". Delegated to by
319
+ // `runLine`; the panel passes `options.op` + `options.args` so `confirm.yes`
320
+ // can re-run without re-parsing a line.
321
+ async function runOperation(session, op, args, options = {}) {
322
+ const clean = (args ?? []).filter((arg) => `${arg ?? ""}` !== "")
323
+ if (op.arity) {
324
+ if (clean.length < op.arity.min || (op.arity.max !== undefined && clean.length > op.arity.max)) {
325
+ session.notice.set({ kind: "error", text: `usage: ${op.signature}` })
326
+ return "error"
327
+ }
328
+ }
329
+ if (op.confirmText && !options.confirmed) {
330
+ session.confirm.set({ text: op.confirmText(clean), line: options.line, op: op.name, args: clean })
331
+ return "confirm"
332
+ }
333
+ try {
334
+ const message = await op.run(session, clean, options)
335
+ if (message) session.notice.set({ kind: "tip", text: message })
336
+ return "ran"
337
+ } catch (error) {
338
+ session.notice.set({ kind: "error", text: `${error?.message ?? error}` })
339
+ return "error"
340
+ }
341
+ }
342
+
343
+ // Function: expandArg
344
+ // Expands one argument token to paths: the command panel's self-contained
345
+ // `$paths:` lists, filesets ($A, $FILE), `~` and paths relative to the active
346
+ // pane.
347
+ function expandArg(session, token) {
348
+ const text = `${token ?? ""}`
349
+ if (text.startsWith("$")) {
350
+ const encoded = decodePathToken(text)
351
+ if (encoded) return encoded
352
+ const set = (session.filesets?.value ?? []).find(
353
+ (candidate) => candidate.id.toLowerCase() === text.toLowerCase(),
354
+ )
355
+ if (!set) throw new Error(`unknown fileset: ${text}`)
356
+ return set.paths.slice()
357
+ }
358
+ if (text.startsWith("~")) {
359
+ const rest = text.replace(/^~\/?/, "")
360
+ return [rest ? join(session.fs.home(), rest) : session.fs.home()]
361
+ }
362
+ if (text.startsWith("/")) return [normalize(text)]
363
+ const base = session.panes[session.active.value]?.path.value ?? session.fs.cwd()
364
+ return [join(base, text)]
365
+ }
366
+
367
+ // Function: expandAll
368
+ // Expands every token to paths, flattened in order.
369
+ function expandAll(session, tokens) {
370
+ const paths = []
371
+ for (const token of tokens) paths.push(...expandArg(session, token))
372
+ return paths
373
+ }
374
+
375
+ async function isDir(session, path) {
376
+ const stats = await session.fs.stat(path)
377
+ return Boolean(stats?.isDir)
378
+ }
379
+
380
+ async function refreshPanes(session) {
381
+ await Promise.all([session.panes.left.refresh(), session.panes.right.refresh()])
382
+ }
383
+
384
+ async function previewTarget(session, path, details) {
385
+ const stats = await session.fs.stat(path)
386
+ if (!stats) throw new Error(`not found: ${path}`)
387
+ const target = {
388
+ path,
389
+ name: basename(path),
390
+ isDir: stats.isDir,
391
+ size: stats.size,
392
+ mtimeMs: stats.mtimeMs,
393
+ ext: extname(path),
394
+ }
395
+ if (details) target.details = details
396
+ return target
397
+ }
398
+
399
+ // Function: parseSelection
400
+ // Splits `sel` arguments into a sign, an optional buffer label and the path
401
+ // tokens: `+A paths` adds to buffer A (the source pane), `-B paths` removes
402
+ // from buffer B (the destination pane); a bare path list defaults to `+` on the
403
+ // active pane.
404
+ function parseSelection(args) {
405
+ const match = /^([+-])(\w*)$/.exec(args[0] ?? "")
406
+ if (match) {
407
+ return { sign: match[1], buffer: match[2] || null, tokens: args.slice(1) }
408
+ }
409
+ return { sign: "+", buffer: null, tokens: args }
410
+ }
411
+
412
+ // Function: findBuffer
413
+ // Resolves a selection buffer to a pane's marks cell: the active pane by
414
+ // default, or the pane whose buffer label matches `name` (`A` = source/left,
415
+ // `B` = destination/right).
416
+ function findBuffer(session, name) {
417
+ const active = session.panes[session.active.value] ?? session.panes.left
418
+ if (!name) return active
419
+ const target = `${name}`.toUpperCase()
420
+ for (const paneId of Object.keys(session.panes)) {
421
+ if (PANE_BUFFERS[paneId] === target) return session.panes[paneId]
422
+ }
423
+ throw new Error(`unknown selection buffer: ${name}`)
424
+ }
425
+
426
+ // Function: humanSize
427
+ // Compact byte size for stat output (core-local: components own presentation
428
+ // formatting).
429
+ function humanSize(bytes) {
430
+ const value = Number(bytes ?? 0)
431
+ if (value < 1024) return `${value}B`
432
+ const units = ["K", "M", "G", "T"]
433
+ let size = value / 1024
434
+ let unit = 0
435
+ while (size >= 1024 && unit < units.length - 1) {
436
+ size /= 1024
437
+ unit++
438
+ }
439
+ return `${size >= 100 ? Math.round(size) : size.toFixed(1)}${units[unit]}`
440
+ }
441
+
442
+ export {
443
+ expandAll,
444
+ expandArg,
445
+ decodePathToken,
446
+ getOperation,
447
+ getOperations,
448
+ humanSize,
449
+ pathToken,
450
+ runOperation,
451
+ }
452
+
453
+ // EOF
@@ -0,0 +1,128 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/core/os
7
+ // Host adapter for the operations that leave the model: opening a path with
8
+ // the desktop opener, editing a file with $EDITOR, and running a shell
9
+ // command. Like `core/fs`, the adapter is injected into the session so the
10
+ // web build can provide a null implementation and tests a fake.
11
+ //
12
+ // A host adapter provides:
13
+ // - `open(path)`: Promise<void> - desktop open (xdg-open / open)
14
+ // - `edit(path)`: Promise<void> - $EDITOR (or vi)
15
+ // - `run(command, options)`: Promise<void> - `$SHELL -c command` in
16
+ // `options.cwd`
17
+
18
+ // `node:child_process` is imported lazily: only `createNodeOs` needs it, so
19
+ // bundles that use the null adapter (the web view, tests) never pull in a Node
20
+ // builtin. See `loadChildProcess`.
21
+ let childProcess = null
22
+
23
+ // Function: loadChildProcess
24
+ // Resolves the `node:child_process` module once, on first use.
25
+ function loadChildProcess() {
26
+ childProcess ??= import("node:child_process")
27
+ return childProcess
28
+ }
29
+
30
+ // Function: createNodeOs
31
+ // Node/Bun-backed host adapter. `suspend`/`resume` are optional hooks the TUI
32
+ // uses to release and restore the terminal around interactive commands.
33
+ function createNodeOs({ suspend, resume } = {}) {
34
+ return {
35
+ async open(path) {
36
+ await openWith(path, openerCandidates())
37
+ },
38
+ async edit(path) {
39
+ await withSuspended(suspend, resume, () =>
40
+ runCommand(editorCommand(), [path], {}),
41
+ )
42
+ },
43
+ async run(command, options = {}) {
44
+ await withSuspended(suspend, resume, () =>
45
+ runCommand(shellCommand(), ["-c", command], { cwd: options.cwd }),
46
+ )
47
+ },
48
+ }
49
+ }
50
+
51
+ // Function: createNullOs
52
+ // Host adapter for environments without a shell (web): every operation fails
53
+ // with a clear message, which the dispatch path reports in the command bar.
54
+ function createNullOs() {
55
+ const unsupported = (name) => async () => {
56
+ throw new Error(`${name} is not available in this environment`)
57
+ }
58
+ return {
59
+ open: unsupported("open"),
60
+ edit: unsupported("edit"),
61
+ run: unsupported("run"),
62
+ }
63
+ }
64
+
65
+ // Function: openerCandidates
66
+ // Desktop openers in preference order: $TCMD_OPENER, then `open` on macOS and
67
+ // `gnome-open`/`xdg-open` on Linux. Missing commands fall through to the next
68
+ // candidate.
69
+ function openerCandidates() {
70
+ if (process.env.TCMD_OPENER) return [process.env.TCMD_OPENER]
71
+ return process.platform === "darwin" ? ["open"] : ["gnome-open", "xdg-open"]
72
+ }
73
+
74
+ async function openWith(path, candidates) {
75
+ let lastError
76
+ for (const command of candidates) {
77
+ try {
78
+ await runDetached(command, [path])
79
+ return
80
+ } catch (error) {
81
+ lastError = error
82
+ }
83
+ }
84
+ throw lastError ?? new Error("no desktop opener found")
85
+ }
86
+
87
+ function editorCommand() {
88
+ return process.env.EDITOR || process.env.VISUAL || "vi"
89
+ }
90
+
91
+ function shellCommand() {
92
+ return process.env.SHELL || "/bin/sh"
93
+ }
94
+
95
+ async function withSuspended(suspend, resume, fn) {
96
+ if (suspend) suspend()
97
+ try {
98
+ return await fn()
99
+ } finally {
100
+ if (resume) resume()
101
+ }
102
+ }
103
+
104
+ async function runCommand(command, args, options) {
105
+ const { spawn } = await loadChildProcess()
106
+ return new Promise((resolve, reject) => {
107
+ const child = spawn(command, args, { stdio: "inherit", ...options })
108
+ child.on("error", reject)
109
+ child.on("exit", (code, signal) => {
110
+ if (code === 0) resolve()
111
+ else reject(new Error(`${command} exited with ${signal ?? code}`))
112
+ })
113
+ })
114
+ }
115
+
116
+ async function runDetached(command, args) {
117
+ const { spawn } = await loadChildProcess()
118
+ return new Promise((resolve, reject) => {
119
+ const child = spawn(command, args, { detached: true, stdio: "ignore" })
120
+ child.on("error", reject)
121
+ child.unref()
122
+ resolve()
123
+ })
124
+ }
125
+
126
+ export { createNodeOs, createNullOs }
127
+
128
+ // EOF
@@ -0,0 +1,74 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/pane
7
+ // A Pane is one column with a single directory: it owns that directory and
8
+ // proxies its cells (path, entries, cursor, marks, disk, …) as pane-level
9
+ // cells, so views and commands address the pane directly. Navigation is async
10
+ // and updates the directory's `rawEntries` in place.
11
+
12
+ import { cell } from "select/state/cells"
13
+ import { createDirectory } from "./directory.js"
14
+
15
+ // Constant: PANE_BUFFERS
16
+ // The selection-buffer label per pane: the left (source) pane is `$A`, the
17
+ // right (destination) pane is `$B`, so scripting keeps two named buffers.
18
+ const PANE_BUFFERS = { left: "A", right: "B" }
19
+
20
+ // Constant: PANE_LABELS
21
+ // The pane role labels: the left pane is the source (SRC), the right the
22
+ // destination (DST).
23
+ const PANE_LABELS = { left: "SRC", right: "DST" }
24
+
25
+ // Function: createPane
26
+ // Creates a pane bound to a filesystem adapter at `path`.
27
+ //
28
+ // Example:
29
+ // ```javascript
30
+ // const pane = createPane({ fs: createNodeFs(), path: "/home" })
31
+ // await pane.refresh()
32
+ // pane.entries.value // sorted, filtered FileEntry[]
33
+ // ```
34
+ function createPane({ fs, path: initialPath } = {}) {
35
+ const pane = {}
36
+ pane.fs = fs
37
+ pane.directory = createDirectory({ fs, path: initialPath })
38
+
39
+ // Proxy cells mirror the directory, so views address the pane uniformly.
40
+ const dir = pane.directory
41
+ pane.path = dir.path
42
+ pane.rawEntries = dir.rawEntries
43
+ pane.loading = dir.loading
44
+ pane.error = dir.error
45
+ pane.filter = dir.filter
46
+ pane.sort = dir.sort
47
+ pane.entries = dir.entries
48
+ pane.cursor = dir.cursor
49
+ pane.cursorStat = dir.cursorStat
50
+ pane.marked = dir.marked
51
+ pane.disk = dir.disk
52
+ pane.segments = dir.segments
53
+
54
+ // Function: current
55
+ // The pane's directory.
56
+ pane.current = () => pane.directory
57
+
58
+ // Function: refresh / navigate / up / open / back / forward /
59
+ // currentEntry / toggleMark / clearFilter — delegate to the directory.
60
+ pane.refresh = () => pane.directory.refresh()
61
+ pane.navigate = (path) => pane.directory.navigate(path)
62
+ pane.up = () => pane.directory.up()
63
+ pane.open = () => pane.directory.open()
64
+ pane.back = () => pane.directory.back()
65
+ pane.forward = () => pane.directory.forward()
66
+ pane.currentEntry = () => pane.directory.currentEntry()
67
+ pane.toggleMark = () => pane.directory.toggleMark()
68
+ pane.clearFilter = () => pane.directory.clearFilter()
69
+ pane.dispose = () => pane.directory.dispose()
70
+
71
+ return pane
72
+ }
73
+
74
+ export { PANE_BUFFERS, PANE_LABELS, createPane }