@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.
- package/README.md +121 -38
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- package/package.json +4 -3
- package/public/index.html +14 -0
- package/public/main.js +17501 -0
- package/public/style.css +901 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +28 -12
- package/src/js/tcmd/components/App.js +135 -85
- package/src/js/tcmd/components/App.xml +64 -15
- package/src/js/tcmd/components/BookmarkList.html +3 -2
- package/src/js/tcmd/components/BookmarkList.js +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +17 -0
- package/src/js/tcmd/components/CommandArgRow.js +86 -26
- package/src/js/tcmd/components/CommandArgRow.xml +31 -4
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +3 -3
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +1 -0
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +54 -52
- package/src/js/tcmd/components/CommandPanel.xml +38 -24
- package/src/js/tcmd/components/Confirm.html +1 -1
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +1 -1
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePreview.html +14 -3
- package/src/js/tcmd/components/FilePreview.js +73 -10
- package/src/js/tcmd/components/FilePreview.xml +19 -6
- package/src/js/tcmd/components/FileRow.html +1 -1
- package/src/js/tcmd/components/FileRow.js +10 -3
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
- package/src/js/tcmd/components/Help.html +7 -0
- package/src/js/tcmd/components/Help.js +109 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/icons.js +39 -5
- package/src/js/tcmd/components/index.js +3 -4
- package/src/js/tcmd/components/rows.js +37 -33
- package/src/js/tcmd/core/commands.js +422 -180
- package/src/js/tcmd/core/directory.js +145 -95
- package/src/js/tcmd/core/fs.js +29 -2
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/keymap.js +3 -3
- package/src/js/tcmd/core/operations.js +108 -15
- package/src/js/tcmd/core/pane.js +31 -8
- package/src/js/tcmd/core/panel.js +172 -82
- package/src/js/tcmd/core/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +280 -152
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +26 -5
- package/src/js/tcmd/tui/catalogue.js +6 -19
- package/src/js/tcmd/tui/index.js +1 -16
- package/src/js/tcmd/tui/theme.js +5 -0
- package/src/js/tcmd/web/app.js +158 -32
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +45 -39
- package/src/js/tcmd/web/effects.js +143 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/seed.js +3 -3
- package/src/js/tcmd/web/server.js +6 -4
- package/src/js/tcmd/web/theme.js +1 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/CommandPalette.xml +0 -16
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- 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
|
|
18
|
+
import { batch, cell, derived } from "select/state/cells"
|
|
14
19
|
import { viewEntries } from "./entries.js"
|
|
15
|
-
import {
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
102
|
-
//
|
|
103
|
-
|
|
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
|
|
115
|
-
// records the step in the history and
|
|
116
|
-
// current path does not duplicate the
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
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
|
|
132
|
-
if (
|
|
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
|
-
//
|
|
180
|
+
// Restores the next history entry; no-op at the end of the history.
|
|
139
181
|
dir.forward = async () => {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
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
|
|
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
|
|
150
|
-
|
|
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.
|
|
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
|
-
//
|
|
236
|
+
// Cancels any in-flight read so a late listing can no longer publish.
|
|
186
237
|
dir.dispose = () => {
|
|
187
|
-
|
|
188
|
-
stopCursorWatch()
|
|
238
|
+
readToken += 1
|
|
189
239
|
}
|
|
190
240
|
|
|
191
241
|
return dir
|
package/src/js/tcmd/core/fs.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|