@ssebastien/tcmd 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -58
- package/desktop/dist/index.html +13 -0
- package/desktop/dist/main.js +20298 -0
- package/desktop/dist/style.css +1515 -0
- package/desktop/frontend/index.html +13 -0
- package/package.json +17 -5
- package/public/fonts/OFL.txt +93 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +5066 -1888
- package/public/style.css +660 -46
- package/scripts/build-desktop.js +31 -0
- package/src/js/tcmd/components/App.html +56 -6
- package/src/js/tcmd/components/App.js +233 -58
- package/src/js/tcmd/components/App.xml +27 -22
- package/src/js/tcmd/components/BookmarkList.html +4 -5
- package/src/js/tcmd/components/BookmarkList.js +14 -40
- package/src/js/tcmd/components/BookmarkList.xml +1 -2
- package/src/js/tcmd/components/BookmarkRow.html +2 -1
- package/src/js/tcmd/components/BookmarkRow.js +14 -9
- package/src/js/tcmd/components/BookmarkRow.xml +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +18 -7
- package/src/js/tcmd/components/CommandArgRow.js +100 -44
- package/src/js/tcmd/components/CommandArgRow.xml +39 -15
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +31 -25
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.js +24 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +13 -13
- package/src/js/tcmd/components/CommandPanel.js +57 -33
- package/src/js/tcmd/components/CommandPanel.xml +30 -20
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.js +21 -7
- package/src/js/tcmd/components/FilePicker.html +10 -0
- package/src/js/tcmd/components/FilePicker.js +43 -0
- package/src/js/tcmd/components/FilePicker.xml +13 -0
- package/src/js/tcmd/components/FilePickerRow.html +6 -0
- package/src/js/tcmd/components/FilePickerRow.js +27 -0
- package/src/js/tcmd/components/FilePickerRow.xml +10 -0
- package/src/js/tcmd/components/FilePreview.html +1 -2
- package/src/js/tcmd/components/FilePreview.js +40 -4
- package/src/js/tcmd/components/FilePreview.xml +17 -10
- package/src/js/tcmd/components/FileRow.html +1 -0
- package/src/js/tcmd/components/FileRow.js +60 -9
- package/src/js/tcmd/components/Frame.js +57 -31
- package/src/js/tcmd/components/Help.html +12 -4
- package/src/js/tcmd/components/Help.js +198 -47
- package/src/js/tcmd/components/Help.xml +13 -4
- package/src/js/tcmd/components/HelpRow.html +8 -0
- package/src/js/tcmd/components/HelpRow.xml +7 -0
- package/src/js/tcmd/components/OverlayList.html +8 -0
- package/src/js/tcmd/components/OverlayList.js +69 -0
- package/src/js/tcmd/components/OverlayList.xml +13 -0
- package/src/js/tcmd/components/OverlayRow.html +5 -0
- package/src/js/tcmd/components/OverlayRow.js +47 -0
- package/src/js/tcmd/components/OverlayRow.xml +9 -0
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +137 -0
- package/src/js/tcmd/components/index.js +8 -3
- package/src/js/tcmd/components/rows.js +149 -25
- package/src/js/tcmd/core/base64.js +64 -0
- package/src/js/tcmd/core/bookmarks.js +45 -8
- package/src/js/tcmd/core/commands.js +612 -136
- package/src/js/tcmd/core/concurrency.js +59 -0
- package/src/js/tcmd/core/decompress.js +117 -0
- package/src/js/tcmd/core/directory.js +81 -5
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +410 -53
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +39 -3
- package/src/js/tcmd/core/operations.js +735 -63
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +4 -2
- package/src/js/tcmd/core/panel.js +179 -93
- package/src/js/tcmd/core/path.js +30 -3
- package/src/js/tcmd/core/permissions.js +107 -0
- package/src/js/tcmd/core/picker.js +156 -0
- package/src/js/tcmd/core/preview.js +102 -0
- package/src/js/tcmd/core/session.js +286 -116
- package/src/js/tcmd/core/watch.js +153 -0
- package/src/js/tcmd/desktop/backend/api.js +165 -0
- package/src/js/tcmd/desktop/backend/fs.js +626 -0
- package/src/js/tcmd/desktop/backend/main.ts +9 -0
- package/src/js/tcmd/desktop/bookmarks.js +23 -0
- package/src/js/tcmd/desktop/fs.js +91 -0
- package/src/js/tcmd/desktop/main.js +194 -0
- package/src/js/tcmd/desktop/os.js +33 -0
- package/src/js/tcmd/tui/app.js +111 -74
- package/src/js/tcmd/tui/catalogue.js +232 -23
- package/src/js/tcmd/tui/index.js +18 -4
- package/src/js/tcmd/tui/opentui.js +33 -0
- package/src/js/tcmd/tui/syntax.js +91 -0
- package/src/js/tcmd/tui/theme.js +27 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +114 -56
- package/src/js/tcmd/web/catalogue.js +41 -4
- package/src/js/tcmd/web/effects.js +94 -14
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +1 -1
- package/src/js/tcmd/web/server.js +118 -17
- package/src/js/tcmd/web/theme.js +3 -0
- package/tinyjs.json +16 -0
package/src/js/tcmd/core/os.js
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// - `edit(path)`: Promise<void> - $EDITOR (or vi)
|
|
15
15
|
// - `run(command, options)`: Promise<void> - `$SHELL -c command` in
|
|
16
16
|
// `options.cwd`
|
|
17
|
+
// - `copy(text)`: boolean - best-effort clipboard write. The TUI sends OSC 52
|
|
18
|
+
// through the renderer; the browser and desktop webview use the Clipboard
|
|
19
|
+
// API. False when the host has no clipboard.
|
|
20
|
+
// - `canEdit`: boolean - whether `edit` can launch an editor at all. A host
|
|
21
|
+
// without one (the web's null adapter) sets it false, so Enter on a file
|
|
22
|
+
// falls back to the fullscreen inspector instead of erroring (see
|
|
23
|
+
// `core/commands#pane.enter`).
|
|
17
24
|
|
|
18
25
|
// `node:child_process` is imported lazily: only `createNodeOs` needs it, so
|
|
19
26
|
// bundles that use the null adapter (the web view, tests) never pull in a Node
|
|
@@ -29,9 +36,14 @@ function loadChildProcess() {
|
|
|
29
36
|
|
|
30
37
|
// Function: createNodeOs
|
|
31
38
|
// Node/Bun-backed host adapter. `suspend`/`resume` are optional hooks the TUI
|
|
32
|
-
// uses to release and restore the terminal around interactive commands
|
|
33
|
-
|
|
39
|
+
// uses to release and restore the terminal around interactive commands;
|
|
40
|
+
// `copy` is the TUI's OSC 52 hook (`renderer.copyToClipboardOSC52`).
|
|
41
|
+
function createNodeOs({ suspend, resume, copy } = {}) {
|
|
34
42
|
return {
|
|
43
|
+
canEdit: true,
|
|
44
|
+
copy(text) {
|
|
45
|
+
return Boolean(copy?.(`${text ?? ""}`))
|
|
46
|
+
},
|
|
35
47
|
async open(path) {
|
|
36
48
|
await openWith(path, openerCandidates())
|
|
37
49
|
},
|
|
@@ -48,14 +60,28 @@ function createNodeOs({ suspend, resume } = {}) {
|
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
// Function: copyToBrowserClipboard
|
|
64
|
+
// Best-effort clipboard write for a browser or desktop webview. The write is
|
|
65
|
+
// asynchronous but fire-and-forget; a refused write (no permission, no user
|
|
66
|
+
// gesture) leaves the clipboard untouched.
|
|
67
|
+
function copyToBrowserClipboard(text) {
|
|
68
|
+
const clipboard = globalThis.navigator?.clipboard
|
|
69
|
+
if (!clipboard?.writeText) return false
|
|
70
|
+
void clipboard.writeText(`${text ?? ""}`).catch(() => {})
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
// Function: createNullOs
|
|
52
75
|
// Host adapter for environments without a shell (web): every operation fails
|
|
53
76
|
// with a clear message, which the dispatch path reports in the command bar.
|
|
77
|
+
// Clipboard access is the exception: the browser has one.
|
|
54
78
|
function createNullOs() {
|
|
55
79
|
const unsupported = (name) => async () => {
|
|
56
80
|
throw new Error(`${name} is not available in this environment`)
|
|
57
81
|
}
|
|
58
82
|
return {
|
|
83
|
+
canEdit: false,
|
|
84
|
+
copy: (text) => copyToBrowserClipboard(text),
|
|
59
85
|
open: unsupported("open"),
|
|
60
86
|
edit: unsupported("edit"),
|
|
61
87
|
run: unsupported("run"),
|
|
@@ -123,6 +149,6 @@ async function runDetached(command, args) {
|
|
|
123
149
|
})
|
|
124
150
|
}
|
|
125
151
|
|
|
126
|
-
export { createNodeOs, createNullOs }
|
|
152
|
+
export { copyToBrowserClipboard, createNodeOs, createNullOs }
|
|
127
153
|
|
|
128
154
|
// EOF
|
package/src/js/tcmd/core/pane.js
CHANGED
|
@@ -51,10 +51,10 @@ function currentPane(session) {
|
|
|
51
51
|
// await pane.refresh()
|
|
52
52
|
// pane.entries.value // sorted, filtered FileEntry[]
|
|
53
53
|
// ```
|
|
54
|
-
function createPane({ fs, path: initialPath, context } = {}) {
|
|
54
|
+
function createPane({ fs, path: initialPath, context, watch } = {}) {
|
|
55
55
|
const pane = {}
|
|
56
56
|
pane.fs = fs
|
|
57
|
-
pane.directory = createDirectory({ fs, path: initialPath, context })
|
|
57
|
+
pane.directory = createDirectory({ fs, path: initialPath, context, watch })
|
|
58
58
|
|
|
59
59
|
// Proxy cells mirror the directory, so views address the pane uniformly.
|
|
60
60
|
const dir = pane.directory
|
|
@@ -70,6 +70,7 @@ function createPane({ fs, path: initialPath, context } = {}) {
|
|
|
70
70
|
pane.disk = dir.disk
|
|
71
71
|
pane.segments = dir.segments
|
|
72
72
|
pane.history = dir.history
|
|
73
|
+
pane.autoRefresh = dir.autoRefresh
|
|
73
74
|
|
|
74
75
|
// Function: current
|
|
75
76
|
// The pane's directory.
|
|
@@ -79,6 +80,7 @@ function createPane({ fs, path: initialPath, context } = {}) {
|
|
|
79
80
|
// currentEntry / toggleMark / setFilter / clearFilter — delegate to the
|
|
80
81
|
// directory.
|
|
81
82
|
pane.refresh = () => pane.directory.refresh()
|
|
83
|
+
pane.setAutoRefresh = (enabled) => pane.directory.setAutoRefresh(enabled)
|
|
82
84
|
pane.navigate = (path, options) => pane.directory.navigate(path, options)
|
|
83
85
|
pane.up = () => pane.directory.up()
|
|
84
86
|
pane.open = () => pane.directory.open()
|
|
@@ -5,30 +5,35 @@
|
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/core/panel
|
|
7
7
|
// The command panel (Commando): the one entry point for the CLI operations
|
|
8
|
-
// (`copy`, `move`, `
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// (`copy`, `move`, `mkdir`, `new`, `chmod`, `hardlink`, `symlink`, `delete`,
|
|
9
|
+
// `run` — the `core/operations` registry). They are grouped by purpose
|
|
10
|
+
// (Command, File, Info, Create, Edit, Delete), using each operation's own
|
|
11
|
+
// `group` metadata, and alphabetized within each group (see
|
|
12
|
+
// `filterPanelEntries`). The workspace-view toggle and Quit are listed
|
|
13
|
+
// alongside the CLI operations; other TinyCmd actions keep their keys.
|
|
12
14
|
//
|
|
13
15
|
// For the selected operation, the panel binds its arguments from the live
|
|
14
16
|
// context — a selection argument shows the SRC/DST pane's marked paths (falling
|
|
15
17
|
// back to the cursor entry) and lets the user toggle individual items; a
|
|
16
18
|
// destination argument resolves to the DST pane's directory; textual arguments
|
|
17
|
-
// (a name, a buffer label, a shell command) are typed in place.
|
|
19
|
+
// (a name, a buffer label, a shell command) are typed in place. `chmod` binds
|
|
20
|
+
// its MODE to the permission grid from `core/permissions`.
|
|
18
21
|
//
|
|
19
22
|
// The selection the user tweaks is *transient*: it lives in the draft bindings
|
|
20
23
|
// (`session.panelDraft`), never in the real pane marks. When the draft runs,
|
|
21
24
|
// each selection argument is encoded as a self-contained `$paths:[…]` token, so
|
|
22
|
-
// the resolved paths survive an async confirm (see `expandArg`). The
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
+
// the resolved paths survive an async confirm (see `expandArg`). The draft can
|
|
26
|
+
// also be rendered as a shell-quoted command (`panelCommand`), while the panel
|
|
27
|
+
// itself presents the arguments and an explicit Run action.
|
|
25
28
|
// Everything here is pure: the caller owns the cells; the panel commands in
|
|
26
|
-
// `core/commands` assemble and run.
|
|
29
|
+
// `core/commands` assemble and run. Picker path resolution and row shaping live
|
|
30
|
+
// in `core/picker`.
|
|
27
31
|
|
|
28
|
-
import { hasMatch
|
|
32
|
+
import { hasMatch } from "../vendor/fzy.js"
|
|
29
33
|
import { currentPaneId, PANE_BUFFERS, PANE_LABELS } from "./pane.js"
|
|
30
34
|
import { basename, parent } from "./path.js"
|
|
31
|
-
import { getOperations, pathToken } from "./operations.js"
|
|
35
|
+
import { getOperations, pathToken, shellQuote } from "./operations.js"
|
|
36
|
+
import { PERMISSION_BITS, PERMISSION_CLASSES, permissionItems, permissionOctal, permissionText } from "./permissions.js"
|
|
32
37
|
|
|
33
38
|
// Constant: PANEL_FOCUS
|
|
34
39
|
// The two regions the panel's focus ring moves through: "ops" (the filter and
|
|
@@ -36,89 +41,132 @@ import { getOperations, pathToken } from "./operations.js"
|
|
|
36
41
|
// right pane, where a textual argument owns the keyboard and Space toggles a
|
|
37
42
|
// selection item).
|
|
38
43
|
const PANEL_FOCUS = { OPS: "ops", ARGS: "args" }
|
|
44
|
+
const PANEL_GROUPS = ["Command", "File", "Info", "Create", "Edit", "Delete"]
|
|
39
45
|
|
|
40
46
|
// Function: panelEntries
|
|
41
|
-
// The entries the panel lists: the CLI operations
|
|
42
|
-
//
|
|
47
|
+
// The entries the panel lists: the categorized CLI operations (each carries its
|
|
48
|
+
// own `group`), the workspace-view toggle (named `view` like File's preview
|
|
49
|
+
// operation, but in the Command group) and Quit.
|
|
43
50
|
function panelEntries() {
|
|
44
|
-
|
|
45
|
-
.filter((op) => !op.hidden)
|
|
51
|
+
const entries = getOperations()
|
|
52
|
+
.filter((op) => !op.hidden && op.group)
|
|
46
53
|
.map((op) => ({
|
|
47
54
|
id: `op:${op.name}`,
|
|
48
55
|
name: op.name,
|
|
49
|
-
|
|
50
|
-
signature: op.signature ?? "",
|
|
56
|
+
group: op.group,
|
|
51
57
|
risk: op.risk ?? null,
|
|
52
58
|
matchNames: [op.name, ...(op.aliases ?? [])],
|
|
53
59
|
op,
|
|
54
60
|
}))
|
|
61
|
+
entries.push({
|
|
62
|
+
id: "command:view",
|
|
63
|
+
name: "view",
|
|
64
|
+
group: "Command",
|
|
65
|
+
risk: null,
|
|
66
|
+
matchNames: ["view"],
|
|
67
|
+
commandId: "view.toggleMode",
|
|
68
|
+
// A no-argument command still shows where it acts.
|
|
69
|
+
showPath: true,
|
|
70
|
+
})
|
|
71
|
+
entries.push({
|
|
72
|
+
id: "command:reload",
|
|
73
|
+
name: "reload",
|
|
74
|
+
group: "Command",
|
|
75
|
+
risk: null,
|
|
76
|
+
matchNames: ["reload", "refresh"],
|
|
77
|
+
commandId: "pane.reload",
|
|
78
|
+
// A no-argument command still shows where it acts.
|
|
79
|
+
showPath: true,
|
|
80
|
+
})
|
|
81
|
+
entries.push({
|
|
82
|
+
id: "command:quit",
|
|
83
|
+
name: "quit",
|
|
84
|
+
group: "Command",
|
|
85
|
+
risk: null,
|
|
86
|
+
matchNames: ["quit", "q"],
|
|
87
|
+
commandId: "app.quit",
|
|
88
|
+
})
|
|
89
|
+
return entries
|
|
55
90
|
}
|
|
56
91
|
|
|
57
92
|
// Function: compareEntries
|
|
58
|
-
// The panel's
|
|
93
|
+
// The panel's fixed category order, then alphabetical command order.
|
|
59
94
|
function compareEntries(a, b) {
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// names, or `-Infinity` when none matches (only the signature does).
|
|
66
|
-
function entryScore(entry, query) {
|
|
67
|
-
let best = -Infinity
|
|
68
|
-
for (const name of entry.matchNames ?? []) {
|
|
69
|
-
const haystack = `${name}`.toLowerCase()
|
|
70
|
-
if (hasMatch(query, haystack)) best = Math.max(best, score(query, haystack))
|
|
71
|
-
}
|
|
72
|
-
return best
|
|
95
|
+
const groupA = PANEL_GROUPS.indexOf(`${a?.group ?? "File"}`)
|
|
96
|
+
const groupB = PANEL_GROUPS.indexOf(`${b?.group ?? "File"}`)
|
|
97
|
+
const rankA = groupA < 0 ? PANEL_GROUPS.length : groupA
|
|
98
|
+
const rankB = groupB < 0 ? PANEL_GROUPS.length : groupB
|
|
99
|
+
return rankA - rankB || `${a?.name ?? ""}`.localeCompare(`${b?.name ?? ""}`)
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
// Function: filterPanelEntries
|
|
76
|
-
// Narrows the entry list by
|
|
77
|
-
//
|
|
78
|
-
// score of the best name match, so `open` outranks `copy` for `op`), ties
|
|
79
|
-
// alphabetical. A query that only matches an operation's signature (`buf` ->
|
|
80
|
-
// `sel [+|-][BUF] PATH...`) keeps it listable, at the bottom.
|
|
103
|
+
// Narrows the entry list by command name or alias and keeps the fixed category
|
|
104
|
+
// order and alphabetical order within each group.
|
|
81
105
|
function filterPanelEntries(entries, query) {
|
|
82
106
|
const list = entries ?? []
|
|
83
107
|
const q = `${query ?? ""}`.trim().toLowerCase()
|
|
84
108
|
if (!q) return [...list].sort(compareEntries)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const signatureMatch = `${entry.signature ?? ""}`.toLowerCase().includes(q)
|
|
89
|
-
if (rank === -Infinity && !signatureMatch) continue
|
|
90
|
-
ranked.push({ entry, score: rank })
|
|
91
|
-
}
|
|
92
|
-
ranked.sort((a, b) => (a.score !== b.score ? b.score - a.score : compareEntries(a.entry, b.entry)))
|
|
93
|
-
return ranked.map((match) => match.entry)
|
|
109
|
+
return list
|
|
110
|
+
.filter((entry) => (entry.matchNames ?? []).some((name) => hasMatch(q, `${name}`.toLowerCase())))
|
|
111
|
+
.sort(compareEntries)
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
// Function: panelEntryRows
|
|
97
|
-
// Projects
|
|
98
|
-
//
|
|
115
|
+
// Projects filtered entries into the command-list column: a group heading and
|
|
116
|
+
// one name-only `op` row per entry, tagged with its index in the *entries* list
|
|
99
117
|
// (what the cursor `panelOpIndex` addresses).
|
|
100
118
|
function panelEntryRows(entries) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
const rows = []
|
|
120
|
+
let previousGroup = null
|
|
121
|
+
for (let index = 0; index < (entries ?? []).length; index++) {
|
|
122
|
+
const entry = entries[index]
|
|
123
|
+
const group = entry.group ?? "File"
|
|
124
|
+
if (group !== previousGroup) {
|
|
125
|
+
rows.push({ type: "group", key: `group:${group.toLowerCase()}`, name: group, group })
|
|
126
|
+
previousGroup = group
|
|
127
|
+
}
|
|
128
|
+
rows.push({
|
|
129
|
+
type: "op",
|
|
130
|
+
key: entry.id,
|
|
131
|
+
entryIndex: index,
|
|
132
|
+
name: entry.name,
|
|
133
|
+
group,
|
|
134
|
+
risk: entry.risk,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return rows
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Function: explorerColumnId
|
|
141
|
+
// The column an explorer argument draws from: the *focused* one, so focusing
|
|
142
|
+
// the parent mirror selects from its own listing — the current directory, a
|
|
143
|
+
// directory — and focusing the preview selects from the directory it mirrors.
|
|
144
|
+
// A focused column without a listing (the inspector over a file) falls back to
|
|
145
|
+
// the pane that owns the current directory, matching `core/commands#pane`.
|
|
146
|
+
function explorerColumnId(session) {
|
|
147
|
+
const active = session.active?.value
|
|
148
|
+
if (active === "parent") return active
|
|
149
|
+
if (
|
|
150
|
+
active === "preview" &&
|
|
151
|
+
session.showPreview?.value &&
|
|
152
|
+
session.previewEntry?.value?.isDir &&
|
|
153
|
+
!session.preview?.value
|
|
154
|
+
) {
|
|
155
|
+
return active
|
|
156
|
+
}
|
|
157
|
+
return currentPaneId(session)
|
|
109
158
|
}
|
|
110
159
|
|
|
111
160
|
// Function: resolvePaneId
|
|
112
161
|
// Maps an argument role to a pane id: "source" -> the working pane (SRC in
|
|
113
|
-
// commander;
|
|
114
|
-
//
|
|
115
|
-
// directory (never an auxiliary column
|
|
116
|
-
//
|
|
162
|
+
// commander; in explorer the focused column, which may be either side of the
|
|
163
|
+
// chain), "dest" -> DST (right), anything else -> the pane holding the current
|
|
164
|
+
// directory (the focused column in explorer, never an auxiliary column with no
|
|
165
|
+
// listing, so operations run against what the cursor shows).
|
|
117
166
|
function resolvePaneId(session, role) {
|
|
118
167
|
if (role === "dest") return "right"
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
}
|
|
168
|
+
if (session.viewMode?.value === "explorer") return explorerColumnId(session)
|
|
169
|
+
if (role === "source") return "left"
|
|
122
170
|
return currentPaneId(session)
|
|
123
171
|
}
|
|
124
172
|
|
|
@@ -163,6 +211,9 @@ function bindArg(session, arg, index) {
|
|
|
163
211
|
const role = arg.role ?? "active"
|
|
164
212
|
const common = {
|
|
165
213
|
index,
|
|
214
|
+
// Display order in the argument pane; the argv order stays the
|
|
215
|
+
// operation's own (see `panelRowList` and `argTokens`).
|
|
216
|
+
panelOrder: arg.panelOrder ?? index,
|
|
166
217
|
name: arg.name,
|
|
167
218
|
kind: arg.kind,
|
|
168
219
|
role,
|
|
@@ -185,6 +236,21 @@ function bindArg(session, arg, index) {
|
|
|
185
236
|
value: "",
|
|
186
237
|
}
|
|
187
238
|
}
|
|
239
|
+
if (arg.kind === "permissions") {
|
|
240
|
+
// Seed the checkboxes from the first selected path's current mode, so
|
|
241
|
+
// opening chmod starts on the file's real permissions rather than a
|
|
242
|
+
// generic 644. A path outside the listing falls back by kind.
|
|
243
|
+
const pane = session.panes[resolvePaneId(session, role)]
|
|
244
|
+
const entries = entryIndex(pane)
|
|
245
|
+
const path = paneSelectionPaths(pane)[0]
|
|
246
|
+
const entry = path ? entries.get(path) : null
|
|
247
|
+
const mode = Number.isInteger(entry?.mode)
|
|
248
|
+
? entry.mode & 0o777
|
|
249
|
+
: entry?.isDir
|
|
250
|
+
? 0o755
|
|
251
|
+
: 0o644
|
|
252
|
+
return { ...common, multi: true, items: permissionItems(mode) }
|
|
253
|
+
}
|
|
188
254
|
if (arg.kind === "dest" || arg.kind === "path") {
|
|
189
255
|
// A destination or a path argument is typeable in place, like a
|
|
190
256
|
// literal, and additionally browsable through the panel's file picker
|
|
@@ -194,7 +260,12 @@ function bindArg(session, arg, index) {
|
|
|
194
260
|
// empty for the picker (or the user) to fill.
|
|
195
261
|
const mode = arg.kind === "dest" ? "dir" : arg.mode ?? "any"
|
|
196
262
|
const commander = (session.viewMode?.value ?? "commander") !== "explorer"
|
|
197
|
-
const value =
|
|
263
|
+
const value =
|
|
264
|
+
arg.prefill === "current"
|
|
265
|
+
? (session.activePath?.value ?? "")
|
|
266
|
+
: mode === "dir" && commander
|
|
267
|
+
? (session.panes?.right?.path?.value ?? "")
|
|
268
|
+
: ""
|
|
198
269
|
return { ...common, textual: true, picker: true, mode, items: [], value }
|
|
199
270
|
}
|
|
200
271
|
if (arg.kind === "buffer") {
|
|
@@ -228,6 +299,7 @@ function argTokens(bindings) {
|
|
|
228
299
|
const paths = selectedPaths(binding)
|
|
229
300
|
return paths.length ? pathToken(paths) : ""
|
|
230
301
|
}
|
|
302
|
+
if (binding.kind === "permissions") return permissionOctal(binding)
|
|
231
303
|
if (binding.kind === "dest") return binding.value ?? ""
|
|
232
304
|
return `${binding.value ?? ""}`.trim()
|
|
233
305
|
})
|
|
@@ -269,17 +341,6 @@ function missingArg(bindings) {
|
|
|
269
341
|
return missingBinding(bindings)?.name ?? null
|
|
270
342
|
}
|
|
271
343
|
|
|
272
|
-
// Function: shellQuote
|
|
273
|
-
// Quotes a token for display as it would appear in a shell: bare when it is
|
|
274
|
-
// already safe (`/home/a`), single-quoted when it contains spaces or shell
|
|
275
|
-
// metacharacters (`'my notes.txt'`). Only the last path segment matters in
|
|
276
|
-
// practice, but quoting the whole token keeps the preview copy-pasteable.
|
|
277
|
-
function shellQuote(token) {
|
|
278
|
-
const text = `${token ?? ""}`
|
|
279
|
-
if (text === "") return "''"
|
|
280
|
-
return /^[A-Za-z0-9_@%+=:,.\/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
|
|
281
|
-
}
|
|
282
|
-
|
|
283
344
|
// Function: panelCommand
|
|
284
345
|
// The command line the draft maps to: each argument rendered as the value it
|
|
285
346
|
// contributes — a selection as its selected paths, a destination/textual arg
|
|
@@ -296,6 +357,10 @@ function panelCommand(bindings) {
|
|
|
296
357
|
else if (binding.optional !== true) parts.push(`«${binding.name}»`)
|
|
297
358
|
continue
|
|
298
359
|
}
|
|
360
|
+
if (binding.kind === "permissions") {
|
|
361
|
+
parts.push(permissionOctal(binding))
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
299
364
|
const value = `${binding.value ?? ""}`.trim()
|
|
300
365
|
if (value) parts.push(shellQuote(value))
|
|
301
366
|
else if (binding.optional !== true) parts.push(`«${binding.name}»`)
|
|
@@ -321,6 +386,7 @@ function panelSummary(binding) {
|
|
|
321
386
|
const count = selectedPaths(binding).length
|
|
322
387
|
return `${pane} · ${count} selected`
|
|
323
388
|
}
|
|
389
|
+
if (binding.kind === "permissions") return permissionText(binding)
|
|
324
390
|
if (binding.kind === "dest" || binding.kind === "path") return binding.value || "(none)"
|
|
325
391
|
if (binding.kind === "buffer") return binding.value || "(active)"
|
|
326
392
|
return binding.value ? binding.value : "(empty)"
|
|
@@ -336,7 +402,12 @@ function panelSummary(binding) {
|
|
|
336
402
|
// list rows are; this stays pure and index-stable.
|
|
337
403
|
function panelRowList(bindings) {
|
|
338
404
|
const rows = []
|
|
339
|
-
|
|
405
|
+
// The pane may show arguments in a different order from the argv (PATH
|
|
406
|
+
// before MODE/OWNER); the binding `index` still keys the draft.
|
|
407
|
+
const ordered = [...(bindings ?? [])].sort(
|
|
408
|
+
(a, b) => (a.panelOrder ?? a.index) - (b.panelOrder ?? b.index),
|
|
409
|
+
)
|
|
410
|
+
for (const binding of ordered) {
|
|
340
411
|
const multi = binding.multi === true
|
|
341
412
|
const items = binding.items ?? []
|
|
342
413
|
// A selection that resolves to a single path is not a list: it renders
|
|
@@ -355,6 +426,7 @@ function panelRowList(bindings) {
|
|
|
355
426
|
optional: binding.optional,
|
|
356
427
|
variadic: binding.variadic,
|
|
357
428
|
textual: binding.textual,
|
|
429
|
+
editable: binding.textual === true,
|
|
358
430
|
picker: binding.picker === true,
|
|
359
431
|
mode: binding.mode ?? null,
|
|
360
432
|
multi,
|
|
@@ -363,6 +435,37 @@ function panelRowList(bindings) {
|
|
|
363
435
|
count: items.length,
|
|
364
436
|
})
|
|
365
437
|
if (single) continue
|
|
438
|
+
// A permission argument renders as one row per class (u/g/o) with its
|
|
439
|
+
// three bits as clickable cells, rather than nine checkbox rows. The
|
|
440
|
+
// bit cursor (`panelPermBit`) selects the cell Space toggles.
|
|
441
|
+
if (binding.kind === "permissions") {
|
|
442
|
+
for (let c = 0; c < PERMISSION_CLASSES.length; c++) {
|
|
443
|
+
const permissionClass = PERMISSION_CLASSES[c]
|
|
444
|
+
const rowIndex = rows.length
|
|
445
|
+
const bits = PERMISSION_BITS.map((bit, i) => {
|
|
446
|
+
const itemIndex = c * PERMISSION_BITS.length + i
|
|
447
|
+
return {
|
|
448
|
+
id: bit.id,
|
|
449
|
+
selected: items[itemIndex]?.selected === true,
|
|
450
|
+
itemIndex,
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
rows.push({
|
|
454
|
+
type: "perm",
|
|
455
|
+
key: `perm:${binding.index}:${permissionClass.id}`,
|
|
456
|
+
rowIndex,
|
|
457
|
+
argIndex: binding.index,
|
|
458
|
+
classId: permissionClass.id,
|
|
459
|
+
label: permissionClass.label,
|
|
460
|
+
bits,
|
|
461
|
+
// Click payloads: arg index, item index, row index.
|
|
462
|
+
keyR: `${binding.index}:${bits[0].itemIndex}:${rowIndex}`,
|
|
463
|
+
keyW: `${binding.index}:${bits[1].itemIndex}:${rowIndex}`,
|
|
464
|
+
keyX: `${binding.index}:${bits[2].itemIndex}:${rowIndex}`,
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
366
469
|
for (let i = 0; i < items.length; i++) {
|
|
367
470
|
const item = items[i]
|
|
368
471
|
const selected = item.selected
|
|
@@ -392,23 +495,6 @@ function rowAt(rows, index) {
|
|
|
392
495
|
return (rows ?? [])[index] ?? null
|
|
393
496
|
}
|
|
394
497
|
|
|
395
|
-
// Function: pickerRowList
|
|
396
|
-
// Flattens the picker's visible listing into the rows the panel's argument
|
|
397
|
-
// column renders while a path is being picked. The shape mirrors a
|
|
398
|
-
// file-listing row (`name`, `path`, `isDir`) so the shared row component can
|
|
399
|
-
// render both; a symlink carries its raw target for the summary.
|
|
400
|
-
function pickerRowList(entries) {
|
|
401
|
-
return (entries ?? []).map((entry) => ({
|
|
402
|
-
type: "entry",
|
|
403
|
-
key: entry.path,
|
|
404
|
-
name: entry.name,
|
|
405
|
-
path: entry.path,
|
|
406
|
-
isDir: entry.isDir === true,
|
|
407
|
-
isSymlink: entry.isSymlink === true,
|
|
408
|
-
target: entry.target ?? null,
|
|
409
|
-
}))
|
|
410
|
-
}
|
|
411
|
-
|
|
412
498
|
// Function: isTextualRow
|
|
413
499
|
// True when the cursor row is an argument header that takes free text, so the
|
|
414
500
|
// panel focuses its edit field.
|
|
@@ -450,6 +536,7 @@ export { decodePathToken, pathToken } from "./operations.js"
|
|
|
450
536
|
|
|
451
537
|
export {
|
|
452
538
|
PANEL_FOCUS,
|
|
539
|
+
PANEL_GROUPS,
|
|
453
540
|
argvFor,
|
|
454
541
|
bindArg,
|
|
455
542
|
bindArgs,
|
|
@@ -465,7 +552,6 @@ export {
|
|
|
465
552
|
panelRunnable,
|
|
466
553
|
panelSummary,
|
|
467
554
|
paneSelectionPaths,
|
|
468
|
-
pickerRowList,
|
|
469
555
|
rowAt,
|
|
470
556
|
selectedPaths,
|
|
471
557
|
withArgValue,
|
package/src/js/tcmd/core/path.js
CHANGED
|
@@ -37,9 +37,11 @@ function split(p) {
|
|
|
37
37
|
|
|
38
38
|
// Function: extname
|
|
39
39
|
// Lowercased extension of the last segment, without the dot; null when the
|
|
40
|
-
// name has no extension (or is a dotfile).
|
|
40
|
+
// name has no extension (or is a dotfile). A bare filename (no separator), the
|
|
41
|
+
// common case when building a listing, skips the `basename` split.
|
|
41
42
|
function extname(p) {
|
|
42
|
-
const
|
|
43
|
+
const text = `${p ?? ""}`
|
|
44
|
+
const name = text.indexOf(SEP) < 0 ? text : basename(text)
|
|
43
45
|
const at = name.lastIndexOf(".")
|
|
44
46
|
return at > 0 ? name.slice(at + 1).toLowerCase() : null
|
|
45
47
|
}
|
|
@@ -52,6 +54,31 @@ function prefix(p, count) {
|
|
|
52
54
|
return SEP + parts.slice(0, Math.max(0, count)).join(SEP)
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
// Function: relative
|
|
58
|
+
// Expresses `target` relative to the absolute directory `base`, using `..`
|
|
59
|
+
// segments when it sits outside: `relative("/a", "/a/b/c")` is `"b/c"` and
|
|
60
|
+
// `relative("/a/b", "/a/c")` is `"../c"`. Equal paths collapse to `"."`. A
|
|
61
|
+
// non-absolute input comes back unchanged, since there is nothing meaningful
|
|
62
|
+
// to relativize against.
|
|
63
|
+
function relative(base, target) {
|
|
64
|
+
const from = `${base ?? ""}`
|
|
65
|
+
const to = `${target ?? ""}`
|
|
66
|
+
if (!from.startsWith(SEP) || !to.startsWith(SEP)) return to
|
|
67
|
+
const baseParts = split(from)
|
|
68
|
+
const targetParts = split(to)
|
|
69
|
+
let common = 0
|
|
70
|
+
while (
|
|
71
|
+
common < baseParts.length &&
|
|
72
|
+
common < targetParts.length &&
|
|
73
|
+
baseParts[common] === targetParts[common]
|
|
74
|
+
) {
|
|
75
|
+
common += 1
|
|
76
|
+
}
|
|
77
|
+
const up = Array.from({ length: baseParts.length - common }, () => "..")
|
|
78
|
+
const down = targetParts.slice(common)
|
|
79
|
+
return [...up, ...down].join(SEP) || "."
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { SEP, basename, extname, join, normalize, parent, prefix, relative, split }
|
|
56
83
|
|
|
57
84
|
// EOF
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-22
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/permissions
|
|
7
|
+
// POSIX permission bits as the command panel edits them: the nine `u/g/o` ×
|
|
8
|
+
// `r/w/x` toggles `chmod` binds its MODE argument to. The tables are the single
|
|
9
|
+
// source of truth for the display order, the octal values and the `rwx`
|
|
10
|
+
// rendering; the panel projects them into rows and the argument row component
|
|
11
|
+
// renders the three cells of each class.
|
|
12
|
+
//
|
|
13
|
+
// Everything here is pure: the binding is the caller's, and the numeric/octal
|
|
14
|
+
// conversions do not mutate it.
|
|
15
|
+
|
|
16
|
+
// Constant: PERMISSION_CLASSES
|
|
17
|
+
// The permission classes in display order (owner, group, other).
|
|
18
|
+
const PERMISSION_CLASSES = [
|
|
19
|
+
{ id: "u", label: "user" },
|
|
20
|
+
{ id: "g", label: "group" },
|
|
21
|
+
{ id: "o", label: "other" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
// Constant: PERMISSION_BITS
|
|
25
|
+
// The bits of each class in display order (read, write, execute).
|
|
26
|
+
const PERMISSION_BITS = [
|
|
27
|
+
{ id: "r", label: "read" },
|
|
28
|
+
{ id: "w", label: "write" },
|
|
29
|
+
{ id: "x", label: "execute" },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
// Constant: PERMISSION_VALUES
|
|
33
|
+
// The octal value of each `class:bit` key.
|
|
34
|
+
const PERMISSION_VALUES = {
|
|
35
|
+
"u:r": 0o400, "u:w": 0o200, "u:x": 0o100,
|
|
36
|
+
"g:r": 0o040, "g:w": 0o020, "g:x": 0o010,
|
|
37
|
+
"o:r": 0o004, "o:w": 0o002, "o:x": 0o001,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Function: permissionItems
|
|
41
|
+
// The nine `u/g/o` × `r/w/x` toggles for a permission argument, seeded from an
|
|
42
|
+
// existing file mode. Each item is a checkbox in the panel's item list: its
|
|
43
|
+
// `key` is the stable `class:bit` key the token builder reads back.
|
|
44
|
+
function permissionItems(mode) {
|
|
45
|
+
const bits = Number(mode) & 0o777
|
|
46
|
+
const items = []
|
|
47
|
+
for (const permissionClass of PERMISSION_CLASSES) {
|
|
48
|
+
for (const bit of PERMISSION_BITS) {
|
|
49
|
+
const key = `${permissionClass.id}:${bit.id}`
|
|
50
|
+
items.push({
|
|
51
|
+
key,
|
|
52
|
+
name: bit.label,
|
|
53
|
+
parent: permissionClass.label,
|
|
54
|
+
isDir: false,
|
|
55
|
+
selected: (bits & PERMISSION_VALUES[key]) !== 0,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return items
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Function: permissionMode
|
|
63
|
+
// The numeric mode a permission binding currently selects.
|
|
64
|
+
function permissionMode(binding) {
|
|
65
|
+
let mode = 0
|
|
66
|
+
for (const item of binding?.items ?? []) {
|
|
67
|
+
if (item.selected) mode |= PERMISSION_VALUES[item.key] ?? 0
|
|
68
|
+
}
|
|
69
|
+
return mode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Function: permissionOctal
|
|
73
|
+
// The three-digit octal token a permission binding contributes to argv.
|
|
74
|
+
function permissionOctal(binding) {
|
|
75
|
+
return permissionMode(binding).toString(8).padStart(3, "0")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Function: permissionSymbolic
|
|
79
|
+
// The `rwx`-style rendering of a numeric mode.
|
|
80
|
+
function permissionSymbolic(mode) {
|
|
81
|
+
return PERMISSION_CLASSES.map((permissionClass) =>
|
|
82
|
+
PERMISSION_BITS.map((bit) =>
|
|
83
|
+
mode & PERMISSION_VALUES[`${permissionClass.id}:${bit.id}`] ? bit.id : "-",
|
|
84
|
+
).join(""),
|
|
85
|
+
).join("")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Function: permissionText
|
|
89
|
+
// The binding's value as the panel shows it: the octal mode and its symbolic
|
|
90
|
+
// form (`644 · rw-r--r--`).
|
|
91
|
+
function permissionText(binding) {
|
|
92
|
+
const mode = permissionMode(binding)
|
|
93
|
+
return `${permissionOctal(binding)} · ${permissionSymbolic(mode)}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
PERMISSION_BITS,
|
|
98
|
+
PERMISSION_CLASSES,
|
|
99
|
+
PERMISSION_VALUES,
|
|
100
|
+
permissionItems,
|
|
101
|
+
permissionMode,
|
|
102
|
+
permissionOctal,
|
|
103
|
+
permissionSymbolic,
|
|
104
|
+
permissionText,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// EOF
|