@ssebastien/tcmd 0.1.3 → 0.1.4

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 (79) hide show
  1. package/README.md +121 -38
  2. package/bin/tcmd +10 -2
  3. package/bin/tcmd.js +13 -2
  4. package/package.json +4 -3
  5. package/public/index.html +14 -0
  6. package/public/main.js +17501 -0
  7. package/public/style.css +901 -0
  8. package/scripts/serve-web.js +61 -0
  9. package/src/js/tcmd/components/App.html +28 -12
  10. package/src/js/tcmd/components/App.js +135 -85
  11. package/src/js/tcmd/components/App.xml +64 -15
  12. package/src/js/tcmd/components/BookmarkList.html +3 -2
  13. package/src/js/tcmd/components/BookmarkList.js +1 -1
  14. package/src/js/tcmd/components/Breadcrumbs.js +1 -1
  15. package/src/js/tcmd/components/CommandArgRow.html +17 -0
  16. package/src/js/tcmd/components/CommandArgRow.js +86 -26
  17. package/src/js/tcmd/components/CommandArgRow.xml +31 -4
  18. package/src/js/tcmd/components/CommandBar.html +1 -1
  19. package/src/js/tcmd/components/CommandBar.js +3 -3
  20. package/src/js/tcmd/components/CommandItem.html +1 -1
  21. package/src/js/tcmd/components/CommandItem.js +1 -0
  22. package/src/js/tcmd/components/CommandPanel.html +29 -0
  23. package/src/js/tcmd/components/CommandPanel.js +54 -52
  24. package/src/js/tcmd/components/CommandPanel.xml +38 -24
  25. package/src/js/tcmd/components/Confirm.html +1 -1
  26. package/src/js/tcmd/components/EntryDetail.html +7 -7
  27. package/src/js/tcmd/components/EntryDetail.js +1 -1
  28. package/src/js/tcmd/components/FileList.html +5 -1
  29. package/src/js/tcmd/components/FileList.js +2 -1
  30. package/src/js/tcmd/components/FilePreview.html +14 -3
  31. package/src/js/tcmd/components/FilePreview.js +73 -10
  32. package/src/js/tcmd/components/FilePreview.xml +19 -6
  33. package/src/js/tcmd/components/FileRow.html +1 -1
  34. package/src/js/tcmd/components/FileRow.js +10 -3
  35. package/src/js/tcmd/components/FilterBar.html +1 -1
  36. package/src/js/tcmd/components/FilterBar.js +14 -4
  37. package/src/js/tcmd/components/FilterBar.xml +1 -1
  38. package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
  39. package/src/js/tcmd/components/Help.html +7 -0
  40. package/src/js/tcmd/components/Help.js +109 -0
  41. package/src/js/tcmd/components/Summary.html +1 -1
  42. package/src/js/tcmd/components/Summary.js +1 -6
  43. package/src/js/tcmd/components/icons.js +39 -5
  44. package/src/js/tcmd/components/index.js +3 -4
  45. package/src/js/tcmd/components/rows.js +37 -33
  46. package/src/js/tcmd/core/commands.js +422 -180
  47. package/src/js/tcmd/core/directory.js +145 -95
  48. package/src/js/tcmd/core/fs.js +29 -2
  49. package/src/js/tcmd/core/history.js +58 -0
  50. package/src/js/tcmd/core/index.js +3 -9
  51. package/src/js/tcmd/core/keymap.js +3 -3
  52. package/src/js/tcmd/core/operations.js +108 -15
  53. package/src/js/tcmd/core/pane.js +31 -8
  54. package/src/js/tcmd/core/panel.js +172 -82
  55. package/src/js/tcmd/core/script.js +7 -95
  56. package/src/js/tcmd/core/selection.js +9 -1
  57. package/src/js/tcmd/core/session.js +280 -152
  58. package/src/js/tcmd/index.js +0 -1
  59. package/src/js/tcmd/tui/app.js +26 -5
  60. package/src/js/tcmd/tui/catalogue.js +6 -19
  61. package/src/js/tcmd/tui/index.js +1 -16
  62. package/src/js/tcmd/tui/theme.js +5 -0
  63. package/src/js/tcmd/web/app.js +158 -32
  64. package/src/js/tcmd/web/bookmarks.js +36 -0
  65. package/src/js/tcmd/web/catalogue.js +45 -39
  66. package/src/js/tcmd/web/effects.js +143 -0
  67. package/src/js/tcmd/web/header.html +1 -1
  68. package/src/js/tcmd/web/header.js +15 -11
  69. package/src/js/tcmd/web/main.js +6 -66
  70. package/src/js/tcmd/web/seed.js +3 -3
  71. package/src/js/tcmd/web/server.js +6 -4
  72. package/src/js/tcmd/web/theme.js +1 -0
  73. package/src/js/tcmd/components/CommandPalette.html +0 -7
  74. package/src/js/tcmd/components/CommandPalette.js +0 -58
  75. package/src/js/tcmd/components/CommandPalette.xml +0 -16
  76. package/src/js/tcmd/components/PaletteRow.html +0 -3
  77. package/src/js/tcmd/components/PaletteRow.js +0 -43
  78. package/src/js/tcmd/components/PaletteRow.xml +0 -4
  79. package/src/js/tcmd/tui/help.js +0 -32
@@ -9,29 +9,18 @@
9
9
  // figures, the selection buffer and the navigation history (back/forward).
10
10
  // A pane owns exactly one directory — the
11
11
  // former multi-tab model is gone — and the pane's cells proxy it directly.
12
+ //
13
+ // Reads commit atomically: the displayed directory (path, listing, filter,
14
+ // marks, cursor) is replaced in one reactive batch once its replacement is
15
+ // ready, and only the latest read may publish (see `read`). Listings carry
16
+ // their stat details, so the cursor entry needs no separate enrichment pass.
12
17
 
13
- import { cell, derived, effect } from "select/state/cells"
18
+ import { batch, cell, derived } from "select/state/cells"
14
19
  import { viewEntries } from "./entries.js"
15
- import { clampCursor, currentEntry, toggleMark } from "./selection.js"
20
+ import { createHistory } from "./history.js"
21
+ import { clampCursor, currentEntry, indexOfPath, toggleMark } from "./selection.js"
16
22
  import { SEP, parent as parentPath, split, prefix } from "./path.js"
17
23
 
18
- // Function: enrichEntry
19
- // Merges `stat` details (ctime, mode, uid, gid) the listing may not carry.
20
- async function enrichEntry(fs, entry) {
21
- if (!entry) return null
22
- const stats = await fs.stat(entry.path).catch(() => null)
23
- if (!stats) return entry
24
- return {
25
- ...entry,
26
- size: entry.isDir ? 0 : (stats.size ?? entry.size),
27
- mtimeMs: stats.mtimeMs ?? entry.mtimeMs,
28
- ctimeMs: stats.ctimeMs ?? entry.ctimeMs,
29
- mode: stats.mode ?? entry.mode,
30
- uid: stats.uid ?? entry.uid,
31
- gid: stats.gid ?? entry.gid,
32
- }
33
- }
34
-
35
24
  // Function: createDirectory
36
25
  // Creates the directory state for one pane, bound to a filesystem adapter.
37
26
  //
@@ -41,7 +30,7 @@ async function enrichEntry(fs, entry) {
41
30
  // await dir.refresh()
42
31
  // dir.entries.value // sorted, filtered FileEntry[]
43
32
  // ```
44
- function createDirectory({ fs, path: initialPath }) {
33
+ function createDirectory({ fs, path: initialPath, context: initialContext }) {
45
34
  const dir = { fs }
46
35
  dir.path = cell(initialPath)
47
36
  dir.rawEntries = cell([])
@@ -54,105 +43,167 @@ function createDirectory({ fs, path: initialPath }) {
54
43
  )
55
44
  dir.cursor = cell(0)
56
45
  dir.disk = cell(null)
57
- // Stat details (ctime, mode, uid, gid) for the entry under the cursor,
58
- // fetched lazily: listings only carry size/mtime, so the pane detail line
59
- // re-stats the selected path when the cursor moves (cached by path).
60
- dir.cursorStat = cell(null)
61
- let cursorStatPath = null
62
46
  // Selection buffer for this directory (the pane's marked paths).
63
47
  dir.marked = cell(new Set())
64
- // Navigation history: visited paths with the current one at
65
- // `history.index`, so back/forward walk the stack like a browser.
66
- dir.history = { stack: [initialPath], index: 0 }
67
-
68
- // A changing filter re-ranks the listing: snap the cursor back to the top
69
- // match. Disposed with the directory (see `dispose`).
70
- const stopFilterWatch = effect([dir.filter], () => dir.cursor.set(0))
71
-
72
- // Re-stat the cursor entry on movement; cache by path so a re-render that
73
- // does not move the cursor does not hit the filesystem again.
74
- const stopCursorWatch = effect([dir.entries, dir.cursor], () => {
75
- const entry = currentEntry(dir.entries.value, dir.cursor.value)
76
- if (!entry || entry.path === cursorStatPath) return
77
- cursorStatPath = entry.path
78
- dir.cursorStat.set(entry)
79
- void enrichEntry(fs, entry).then((enriched) => {
80
- if (cursorStatPath === entry.path) dir.cursorStat.set(enriched)
81
- })
48
+ // An opaque per-entry value the owner captures into the history and gets
49
+ // back on restore. The session uses it to restore the focused column; the
50
+ // directory itself never inspects it.
51
+ let context = initialContext
52
+ const contextGet = () => context?.get?.()
53
+ const contextSet = (value) => context?.set?.(value)
54
+ // Function: snapshot
55
+ // The directory's restorable view state: path, filter, cursor, marks and
56
+ // the owner's context.
57
+ const snapshot = () => ({
58
+ path: dir.path.value,
59
+ filter: dir.filter.value,
60
+ cursor: dir.cursor.value,
61
+ marks: new Set(dir.marked.value),
62
+ context: contextGet(),
82
63
  })
64
+ // Navigation history: full view snapshots with the current one at
65
+ // `history.index`, so back/forward restore position, selection and filter
66
+ // like a browser.
67
+ dir.history = createHistory(snapshot())
68
+ // Cursor memory: the last cursor position per path, so re-entering a
69
+ // directory (a fresh `open`, not a history step) lands on the entry that
70
+ // was last selected there.
71
+ const cursorMemory = new Map()
72
+ let readToken = 0
73
+
74
+ // Function: setContext
75
+ // Replaces the owner context captured into history entries.
76
+ dir.setContext = (next) => {
77
+ context = next
78
+ }
83
79
 
84
- // Function: refresh
85
- // Re-reads the directory at `dir.path`, updating `rawEntries` and clamping
86
- // the cursor. Sets `loading` while in flight; failures land in `error`.
87
- dir.refresh = async () => {
88
- dir.loading.set(true)
89
- dir.error.set(null)
80
+ // Function: read
81
+ // Reads `path` and, unless a newer read supersedes it, commits the listing
82
+ // as one atomic update: the displayed directory stays intact until its
83
+ // replacement is ready, and disk figures never delay the listing.
84
+ //
85
+ // Options:
86
+ // - `move`: move to `path` (reset filter and marks, remember the cursor
87
+ // for re-entry). Without it, `read` re-reads the displayed directory in
88
+ // place, preserving filter, marks and cursor (`refresh`).
89
+ // - `record`: also record the move in the history (a navigation). Mirror
90
+ // columns move without recording.
91
+ // - `cursorPath`: land the cursor on that entry when the listing has it.
92
+ // - `restore`: a history snapshot to restore instead of a fresh move.
93
+ const read = async (
94
+ path,
95
+ { move = false, record = false, cursorPath = null, restore = null } = {},
96
+ ) => {
97
+ const token = ++readToken
98
+ batch(() => {
99
+ dir.loading.set(true)
100
+ dir.error.set(null)
101
+ })
90
102
  try {
91
- dir.rawEntries.set(await fs.readDir(dir.path.value))
92
- dir.cursor.set(clampCursor(dir.cursor.value, dir.entries.value.length))
93
- dir.disk.set(await fs.disk(dir.path.value))
103
+ const raw = await fs.readDir(path)
104
+ if (token !== readToken) return
105
+ const isMove = move || Boolean(restore)
106
+ const filter = restore ? restore.filter : isMove ? "" : dir.filter.value
107
+ const entries = viewEntries(raw, filter, dir.sort.value)
108
+ let cursor
109
+ if (restore) {
110
+ cursor = clampCursor(restore.cursor, entries.length)
111
+ } else {
112
+ const remembered = isMove && path !== dir.path.value
113
+ ? (cursorMemory.get(path) ?? 0)
114
+ : dir.cursor.value
115
+ const selected = cursorPath ? indexOfPath(entries, cursorPath) : -1
116
+ cursor = clampCursor(selected >= 0 ? selected : remembered, entries.length)
117
+ }
118
+ // A move that rewrites the filter sets the cursor it computed; no
119
+ // watcher intervenes (the filter cell is written directly, not
120
+ // through `setFilter`).
121
+ batch(() => {
122
+ if (restore) {
123
+ dir.path.set(path)
124
+ dir.filter.set(restore.filter)
125
+ dir.marked.set(new Set(restore.marks))
126
+ dir.disk.set(null)
127
+ contextSet(restore.context)
128
+ } else if (isMove) {
129
+ cursorMemory.set(dir.path.value, dir.cursor.value)
130
+ // Capture the view being left before overwriting it, but
131
+ // only for recorded moves: mirror columns keep no history.
132
+ if (record) dir.history.replace(snapshot())
133
+ dir.path.set(path)
134
+ dir.filter.set("")
135
+ dir.marked.set(new Set())
136
+ dir.disk.set(null)
137
+ }
138
+ dir.rawEntries.set(raw)
139
+ dir.cursor.set(cursor)
140
+ dir.loading.set(false)
141
+ })
142
+ if (record) {
143
+ const entry = { path, filter: "", cursor, marks: new Set(), context: contextGet() }
144
+ // Re-navigating to the current path updates the entry instead of
145
+ // duplicating it.
146
+ if (dir.history.current()?.path === path) dir.history.replace(entry)
147
+ else dir.history.push(entry)
148
+ }
149
+ const disk = await fs.disk(path)
150
+ if (token === readToken) dir.disk.set(disk)
94
151
  } catch (error) {
95
- dir.error.set(`${error?.message ?? error}`)
152
+ if (token === readToken) dir.error.set(`${error?.message ?? error}`)
96
153
  } finally {
97
- dir.loading.set(false)
154
+ if (token === readToken) dir.loading.set(false)
98
155
  }
99
156
  }
100
157
 
101
- // Function: apply
102
- // The actual move to `path`: sets the path, resets filter, marks and
103
- // cursor, then refreshes. History-neutral — navigate/back/forward record
104
- // their own steps around it.
105
- const apply = async (path) => {
106
- dir.path.set(path)
107
- dir.filter.set("")
108
- dir.marked.set(new Set())
109
- dir.cursor.set(0)
110
- await dir.refresh()
111
- }
158
+ // Function: refresh
159
+ // Re-reads the displayed directory, preserving filter, marks and cursor.
160
+ dir.refresh = () => read(dir.path.value)
112
161
 
113
162
  // Function: navigate
114
- // Moves the directory to `path` (resetting filter, marks and cursor),
115
- // records the step in the history and refreshes. Re-navigating to the
116
- // current path does not duplicate the entry.
117
- dir.navigate = async (path) => {
118
- const history = dir.history
119
- if (history.stack[history.index] !== path) {
120
- // A new navigation truncates the forward branch, like a browser.
121
- history.stack.length = history.index + 1
122
- history.stack.push(path)
123
- history.index = history.stack.length - 1
124
- }
125
- await apply(path)
126
- }
163
+ // Moves the directory to `path` (resetting filter and marks, restoring the
164
+ // cursor remembered for `path`), records the step in the history and reads
165
+ // the listing. Re-navigating to the current path does not duplicate the
166
+ // entry. `record: false` mirrors without touching the history (the
167
+ // explorer's parent and inspector columns).
168
+ dir.navigate = (path, { cursorPath = null, record = true } = {}) =>
169
+ read(path, { move: true, record, cursorPath })
127
170
 
128
171
  // Function: back
129
- // Steps one entry back in the navigation history; no-op at its start.
172
+ // Restores the previous history entry — path, filter, cursor and marks —
173
+ // and reads it; no-op at the start of the history.
130
174
  dir.back = async () => {
131
- const history = dir.history
132
- if (history.index <= 0) return
133
- history.index -= 1
134
- await apply(history.stack[history.index])
175
+ const entry = dir.history.back()
176
+ if (entry) await read(entry.path, { restore: entry })
135
177
  }
136
178
 
137
179
  // Function: forward
138
- // Steps one entry forward in the navigation history; no-op at its end.
180
+ // Restores the next history entry; no-op at the end of the history.
139
181
  dir.forward = async () => {
140
- const history = dir.history
141
- if (history.index >= history.stack.length - 1) return
142
- history.index += 1
143
- await apply(history.stack[history.index])
182
+ const entry = dir.history.forward()
183
+ if (entry) await read(entry.path, { restore: entry })
144
184
  }
145
185
 
146
186
  // Function: up
147
- // Navigates to the parent directory; no-op at the filesystem root.
187
+ // Navigates to the parent directory, landing the cursor on the entry we
188
+ // came from — stepping up always returns to the previously selected
189
+ // element. No-op at the filesystem root.
148
190
  dir.up = async () => {
149
- const target = parentPath(dir.path.value)
150
- if (target) await dir.navigate(target)
191
+ const cameFrom = dir.path.value
192
+ const target = parentPath(cameFrom)
193
+ if (!target) return
194
+ await dir.navigate(target, { cursorPath: cameFrom })
195
+ }
196
+
197
+ // Function: setFilter
198
+ // Sets the fuzzy filter and re-anchors the cursor on the first match.
199
+ dir.setFilter = (query) => {
200
+ dir.filter.set(query)
201
+ dir.cursor.set(0)
151
202
  }
152
203
 
153
204
  // Function: clearFilter
154
205
  // Removes the directory's filter, restoring the full listing.
155
- dir.clearFilter = () => dir.filter.set("")
206
+ dir.clearFilter = () => dir.setFilter("")
156
207
 
157
208
  // Function: open
158
209
  // Opens the entry under the cursor: descends into directories.
@@ -182,10 +233,9 @@ function createDirectory({ fs, path: initialPath }) {
182
233
  })
183
234
 
184
235
  // Function: dispose
185
- // Releases the directory's reactive watchers.
236
+ // Cancels any in-flight read so a late listing can no longer publish.
186
237
  dir.dispose = () => {
187
- stopFilterWatch()
188
- stopCursorWatch()
238
+ readToken += 1
189
239
  }
190
240
 
191
241
  return dir
@@ -19,6 +19,12 @@
19
19
  // - `trash(paths)`, `erase(paths)`: Promise<void> - delete, with/without trash
20
20
  // - `touch(path)`: Promise<void> - create or bump mtime
21
21
  // - `home()`, `cwd()`: string - well-known directories
22
+ //
23
+ // A listing entry additionally reports symlinks as what they point at:
24
+ // `isSymlink` keeps the entry's own identity, `target` is the raw `readlink`
25
+ // text (relative as written, null otherwise), and `isDir`/`size` follow the
26
+ // link — so a link to a directory sorts, opens and previews as a directory,
27
+ // and a link to a file carries the target's size.
22
28
 
23
29
  import fs from "node:fs/promises"
24
30
  import nodeFs from "node:fs"
@@ -70,6 +76,9 @@ function toEntry(name, dirPath, isDir, stats) {
70
76
  path: pathJoin(dirPath, name),
71
77
  isDir,
72
78
  isSymlink: Boolean(stat.isSymlink),
79
+ // The raw `readlink` text — relative when the link was written that
80
+ // way — so the listing and the preview can show where it points.
81
+ target: stat.target ?? null,
73
82
  isDevice: Boolean(stat.isDevice),
74
83
  size: isDir ? 0 : stat.size ?? 0,
75
84
  mtimeMs: stat.mtimeMs ?? 0,
@@ -100,8 +109,15 @@ function createNodeFs() {
100
109
  const d = dirents[i]
101
110
  const full = pathJoin(path, d.name)
102
111
  const stats = await statSafe(full)
103
- entries[i] = toEntry(d.name, path, d.isDirectory(), {
104
- isSymlink: d.isSymbolicLink(),
112
+ const isLink = d.isSymbolicLink()
113
+ // The dirent type describes the link itself, not what it
114
+ // points at: resolve through the followed stat so a link to a
115
+ // directory is listed (sorted, opened, previewed) as one. A
116
+ // broken link has no stats and stays a plain entry.
117
+ const isDir = d.isDirectory() || (isLink && Boolean(stats?.isDir))
118
+ entries[i] = toEntry(d.name, path, isDir, {
119
+ isSymlink: isLink,
120
+ target: isLink ? await readLinkSafe(full) : null,
105
121
  isDevice: isDeviceNode(d),
106
122
  size: stats?.size ?? 0,
107
123
  mtimeMs: stats?.mtimeMs ?? 0,
@@ -237,6 +253,17 @@ async function statSafe(path) {
237
253
  }
238
254
  }
239
255
 
256
+ // Function: readLinkSafe
257
+ // The raw `readlink` text of `path`, or null when it is not a link (or the
258
+ // link cannot be read). Relative targets stay relative, as written.
259
+ async function readLinkSafe(path) {
260
+ try {
261
+ return await fs.readlink(path)
262
+ } catch (error) {
263
+ return null
264
+ }
265
+ }
266
+
240
267
  // Function: duSafe
241
268
  // Recursive disk usage; unreadable entries contribute 0 and never throw.
242
269
  async function duSafe(path) {
@@ -0,0 +1,58 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-10-14
5
+
6
+ // Module: tcmd/core/history
7
+ // A browser-like history over snapshots: a stack with a cursor (`index`) into
8
+ // it. `push` truncates the forward branch, `back`/`forward` move the cursor and
9
+ // return the snapshot to restore, and `replace` updates the current entry in
10
+ // place. Pure bookkeeping — callers own what a snapshot contains and how to
11
+ // apply it.
12
+
13
+ // Function: createHistory
14
+ // Creates a history seeded with `initial` and the cursor on it.
15
+ function createHistory(initial) {
16
+ const stack = [initial]
17
+ let index = 0
18
+ return {
19
+ stack,
20
+ get index() {
21
+ return index
22
+ },
23
+ // Function: current
24
+ // The snapshot the cursor is on.
25
+ current: () => stack[index],
26
+ // Function: push
27
+ // Records `snapshot` after the cursor, dropping the forward branch.
28
+ push(snapshot) {
29
+ stack.length = index + 1
30
+ stack.push(snapshot)
31
+ index = stack.length - 1
32
+ },
33
+ // Function: replace
34
+ // Updates the current entry in place, so a later return restores the
35
+ // state as it was left (moved cursor, marks, filter).
36
+ replace(snapshot) {
37
+ stack[index] = snapshot
38
+ },
39
+ // Function: back
40
+ // Moves one entry back and returns its snapshot, or null at the start.
41
+ back() {
42
+ if (index <= 0) return null
43
+ index -= 1
44
+ return stack[index]
45
+ },
46
+ // Function: forward
47
+ // Moves one entry forward and returns its snapshot, or null at the end.
48
+ forward() {
49
+ if (index >= stack.length - 1) return null
50
+ index += 1
51
+ return stack[index]
52
+ },
53
+ }
54
+ }
55
+
56
+ export { createHistory }
57
+
58
+ // EOF
@@ -14,6 +14,7 @@ export { createNodeFs } from "./fs.js"
14
14
  export { createNullOs, createNodeOs } from "./os.js"
15
15
  export { filterEntries, rankEntries, sortEntries, viewEntries } from "./entries.js"
16
16
  export { clampCursor, currentEntry, moveCursor, toggleMark } from "./selection.js"
17
+ export { createHistory } from "./history.js"
17
18
  export { PANE_BUFFERS, PANE_LABELS, createPane } from "./pane.js"
18
19
  export { createDirectory } from "./directory.js"
19
20
  export {
@@ -26,14 +27,7 @@ export {
26
27
  export { DEFAULT_KEYMAP, matchKeymap } from "./keymap.js"
27
28
  export { createDispatch, getCommand, getCommands } from "./commands.js"
28
29
  export { expandAll, expandArg, decodePathToken, getOperation, getOperations, pathToken, runOperation } from "./operations.js"
29
- export {
30
- PANEL_FOCUS,
31
- argvFor,
32
- bindArgs,
33
- panelOperations,
34
- panelRowList,
35
- paneSelectionPaths,
36
- } from "./panel.js"
37
- export { parseLine, runLine, scriptItems, tokenize } from "./script.js"
30
+ export { PANEL_FOCUS, argvFor, bindArgs, panelEntries, panelRowList, paneSelectionPaths } from "./panel.js"
31
+ export { parseLine, runLine, tokenize } from "./script.js"
38
32
  export { createSession, load } from "./session.js"
39
33
  export { SEP, basename, extname, join, normalize, parent, prefix, split } from "./path.js"
@@ -28,15 +28,15 @@ const DEFAULT_KEYMAP = [
28
28
  { key: "space", command: "list.mark" },
29
29
  { key: "b", command: "bookmark.record" },
30
30
  { key: "g", command: "bookmark.jump" },
31
- { key: "m", command: "panel.toggle" },
32
31
  { key: ":", command: "panel.open" },
33
- { key: "!", command: "palette.run" },
32
+ { key: "!", command: "panel.shell" },
34
33
  { key: "/", command: "filter.start" },
35
34
  { key: "r", ctrl: true, command: "pane.reload" },
36
35
  { key: "v", command: "view.togglePreview" },
36
+ { key: "v", shift: true, command: "view.maximize" },
37
+ { key: "t", command: "view.toggleMode" },
37
38
  { key: "o", shift: true, command: "file.open" },
38
39
  { key: "e", shift: true, command: "file.edit" },
39
- { key: "p", ctrl: true, command: "palette.toggle" },
40
40
  { key: "q", command: "app.quit" },
41
41
  ]
42
42
 
@@ -4,13 +4,13 @@
4
4
  // Created: 2026-09-27
5
5
 
6
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.
7
+ // The CLI operations: the file-management verbs the command panel runs. An
8
+ // operation receives the session and its raw argument tokens; path arguments
9
+ // resolve filesets ($A … $FILE), `~` and paths relative to the active pane.
10
+ // Operations own their side effects and return a one-line message (shown in
11
+ // the command bar) or undefined.
12
12
  //
13
- // Registry entries carry the presentation metadata the palette renders:
13
+ // Registry entries carry the presentation metadata the panel renders:
14
14
  // `signature`, `description`, `aliases` and an optional `confirmText`. An
15
15
  // optional `risk` ("caution" for a move, "destructive" for delete/erase) lets
16
16
  // the command panel tint the operation by how dangerous it is.
@@ -26,8 +26,8 @@
26
26
  // - "literal": free text (a name), edited in the panel's text field.
27
27
  // - "raw": the rest of the line, unparsed (a shell command).
28
28
 
29
- import { basename, extname, join, normalize } from "./path.js"
30
- import { PANE_BUFFERS } from "./pane.js"
29
+ import { basename, extname, join, normalize, parent as parentOf } from "./path.js"
30
+ import { currentPane, currentPaneId, PANE_BUFFERS } from "./pane.js"
31
31
 
32
32
  // Constant: PATHS_PREFIX
33
33
  // The prefix of a self-contained path-list token the command panel encodes
@@ -62,7 +62,7 @@ const operations = [
62
62
  description: "Copy files and directories",
63
63
  arity: { min: 2, max: 2 },
64
64
  args: [
65
- { name: "FROM", kind: "selection", role: "source" },
65
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
66
66
  { name: "TO", kind: "dest" },
67
67
  ],
68
68
  async run(session, args) {
@@ -89,7 +89,7 @@ const operations = [
89
89
  risk: "caution",
90
90
  arity: { min: 2, max: 2 },
91
91
  args: [
92
- { name: "FROM", kind: "selection", role: "source" },
92
+ { name: "FROM", kind: "selection", role: "source", variadic: true },
93
93
  { name: "TO", kind: "dest" },
94
94
  ],
95
95
  async run(session, args) {
@@ -109,6 +109,67 @@ const operations = [
109
109
  return `moved ${sources.length} path${sources.length === 1 ? "" : "s"} to ${target}`
110
110
  },
111
111
  },
112
+ {
113
+ name: "rename",
114
+ signature: "rename FIND REPLACE PATH...",
115
+ description: "Rename paths with a literal find-and-replace",
116
+ risk: "caution",
117
+ arity: { min: 3 },
118
+ args: [
119
+ { name: "FIND", kind: "literal" },
120
+ { name: "REPLACE", kind: "literal" },
121
+ { name: "PATH", kind: "selection", role: "active", variadic: true },
122
+ ],
123
+ // The live mapping the panel previews instead of the command line: one
124
+ // `old → new` line per path, an unchanged path marked as such, and an
125
+ // invalid name or a duplicate target flagged before anything runs.
126
+ preview(session, argv) {
127
+ const [find, replace] = argv
128
+ if (!find || !replace) return null
129
+ let paths
130
+ try {
131
+ paths = expandAll(session, argv.slice(2))
132
+ } catch (error) {
133
+ return null
134
+ }
135
+ if (!paths.length) return null
136
+ const plan = renamePlan(find, replace, paths)
137
+ const lines = plan.map((row) => {
138
+ if (row.error) return `${row.name} → ⚠ ${row.error}`
139
+ if (!row.changed) return `${row.name} (unchanged)`
140
+ return `${row.name} → ${row.newName}`
141
+ })
142
+ for (const name of duplicateTargets(plan)) lines.push(`⚠ duplicate target: ${name}`)
143
+ return lines.join("\n")
144
+ },
145
+ async run(session, args) {
146
+ const find = `${args[0] ?? ""}`
147
+ const replace = `${args[1] ?? ""}`
148
+ if (!find) throw new Error("rename needs FIND")
149
+ if (!replace) throw new Error("rename needs REPLACE")
150
+ const paths = expandAll(session, args.slice(2))
151
+ if (!paths.length) throw new Error("rename needs at least one path")
152
+ const plan = renamePlan(find, replace, paths)
153
+ const bad = plan.find((row) => row.error)
154
+ if (bad) throw new Error(`rename ${basename(bad.path)}: ${bad.error}`)
155
+ const duplicates = duplicateTargets(plan)
156
+ if (duplicates.length) throw new Error(`rename: duplicate target ${duplicates[0]}`)
157
+ for (const row of plan) {
158
+ if (!row.changed) continue
159
+ const target = join(parentOf(row.path), row.newName)
160
+ if (await session.fs.stat(target)) throw new Error(`rename: ${row.newName} already exists`)
161
+ }
162
+ let count = 0
163
+ for (const row of plan) {
164
+ if (!row.changed) continue
165
+ await session.fs.move(row.path, join(parentOf(row.path), row.newName))
166
+ count += 1
167
+ }
168
+ await refreshPanes(session)
169
+ if (!count) return "rename: nothing changed"
170
+ return `renamed ${count} path${count === 1 ? "" : "s"}`
171
+ },
172
+ },
112
173
  {
113
174
  name: "delete",
114
175
  signature: "delete PATH...",
@@ -191,7 +252,7 @@ const operations = [
191
252
  if (!tokens.length) throw new Error("sel needs at least one path")
192
253
  const paths = expandAll(session, tokens)
193
254
  const target = findBuffer(session, buffer)
194
- const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[session.active.value] ?? PANE_BUFFERS.left
255
+ const label = buffer ? `${buffer}`.toUpperCase() : PANE_BUFFERS[currentPaneId(session)] ?? PANE_BUFFERS.left
195
256
  const marks = new Set(target.marked.value)
196
257
  for (const path of paths) {
197
258
  if (sign === "-") marks.delete(path)
@@ -286,7 +347,7 @@ const operations = [
286
347
  async run(session, args) {
287
348
  const command = `${args[0] ?? ""}`.trim()
288
349
  if (!command) throw new Error("run needs a command")
289
- const cwd = session.panes[session.active.value]?.path.value
350
+ const cwd = currentPane(session).path.value
290
351
  await session.os.run(command, { cwd })
291
352
  await refreshPanes(session)
292
353
  return `ran ${command}`
@@ -360,7 +421,7 @@ function expandArg(session, token) {
360
421
  return [rest ? join(session.fs.home(), rest) : session.fs.home()]
361
422
  }
362
423
  if (text.startsWith("/")) return [normalize(text)]
363
- const base = session.panes[session.active.value]?.path.value ?? session.fs.cwd()
424
+ const base = currentPane(session).path.value ?? session.fs.cwd()
364
425
  return [join(base, text)]
365
426
  }
366
427
 
@@ -378,7 +439,9 @@ async function isDir(session, path) {
378
439
  }
379
440
 
380
441
  async function refreshPanes(session) {
381
- await Promise.all([session.panes.left.refresh(), session.panes.right.refresh()])
442
+ // Every rendered column: the two panes, plus the explorer's parent mirror
443
+ // and the directory the inspector is showing.
444
+ await Promise.all(Object.values(session.panes).map((pane) => pane.refresh()))
382
445
  }
383
446
 
384
447
  async function previewTarget(session, path, details) {
@@ -414,7 +477,7 @@ function parseSelection(args) {
414
477
  // default, or the pane whose buffer label matches `name` (`A` = source/left,
415
478
  // `B` = destination/right).
416
479
  function findBuffer(session, name) {
417
- const active = session.panes[session.active.value] ?? session.panes.left
480
+ const active = currentPane(session)
418
481
  if (!name) return active
419
482
  const target = `${name}`.toUpperCase()
420
483
  for (const paneId of Object.keys(session.panes)) {
@@ -423,6 +486,35 @@ function findBuffer(session, name) {
423
486
  throw new Error(`unknown selection buffer: ${name}`)
424
487
  }
425
488
 
489
+ // Function: renamePlan
490
+ // The literal find-and-replace mapping a `rename` runs: one row per path with
491
+ // its source basename, its new basename (every occurrence of FIND replaced by
492
+ // REPLACE), whether it changed, and an error when the result is empty or
493
+ // contains a path separator. Pure, so the panel preview and the run agree.
494
+ function renamePlan(find, replace, paths) {
495
+ return (paths ?? []).map((path) => {
496
+ const name = basename(path)
497
+ const newName = name.split(find).join(replace)
498
+ let error = null
499
+ if (!newName) error = "empty name"
500
+ else if (newName.includes("/")) error = "name has a slash"
501
+ return { path, name, newName, changed: error === null && newName !== name, error }
502
+ })
503
+ }
504
+
505
+ // Function: duplicateTargets
506
+ // The new names two or more different paths would collide on.
507
+ function duplicateTargets(plan) {
508
+ const seen = new Set()
509
+ const duplicates = new Set()
510
+ for (const row of plan ?? []) {
511
+ if (!row.changed) continue
512
+ if (seen.has(row.newName)) duplicates.add(row.newName)
513
+ seen.add(row.newName)
514
+ }
515
+ return [...duplicates]
516
+ }
517
+
426
518
  // Function: humanSize
427
519
  // Compact byte size for stat output (core-local: components own presentation
428
520
  // formatting).
@@ -447,6 +539,7 @@ export {
447
539
  getOperations,
448
540
  humanSize,
449
541
  pathToken,
542
+ renamePlan,
450
543
  runOperation,
451
544
  }
452
545