@ssebastien/tcmd 0.1.3 → 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 +256 -62
- package/bin/tcmd +10 -2
- package/bin/tcmd.js +13 -2
- 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 +19 -6
- package/public/fonts/OFL.txt +93 -0
- package/public/index.html +14 -0
- package/public/licenses/LUCIDE.txt +43 -0
- package/public/main.js +20679 -0
- package/public/style.css +1515 -0
- package/scripts/build-desktop.js +31 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +80 -14
- package/src/js/tcmd/components/App.js +339 -114
- package/src/js/tcmd/components/App.xml +76 -22
- package/src/js/tcmd/components/BookmarkList.html +5 -5
- package/src/js/tcmd/components/BookmarkList.js +15 -41
- 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/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +28 -0
- package/src/js/tcmd/components/CommandArgRow.js +146 -30
- package/src/js/tcmd/components/CommandArgRow.xml +55 -4
- package/src/js/tcmd/components/CommandBar.html +2 -2
- package/src/js/tcmd/components/CommandBar.js +33 -27
- package/src/js/tcmd/components/CommandBar.xml +2 -2
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +25 -25
- package/src/js/tcmd/components/CommandItem.xml +2 -2
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +78 -52
- package/src/js/tcmd/components/CommandPanel.xml +49 -25
- package/src/js/tcmd/components/Confirm.html +2 -2
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +22 -8
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- 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 +14 -4
- package/src/js/tcmd/components/FilePreview.js +111 -12
- package/src/js/tcmd/components/FilePreview.xml +28 -8
- package/src/js/tcmd/components/FileRow.html +2 -1
- package/src/js/tcmd/components/FileRow.js +70 -12
- 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/components/Frame.js +324 -0
- package/src/js/tcmd/components/Help.html +15 -0
- package/src/js/tcmd/components/Help.js +260 -0
- 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/{CommandPalette.xml → OverlayList.xml} +2 -5
- 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/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/format.js +4 -23
- package/src/js/tcmd/components/icons.js +176 -5
- package/src/js/tcmd/components/index.js +9 -5
- package/src/js/tcmd/components/rows.js +176 -48
- 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 +978 -260
- 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 +219 -93
- package/src/js/tcmd/core/format.js +36 -0
- package/src/js/tcmd/core/fs.js +438 -54
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/journal.js +93 -0
- package/src/js/tcmd/core/keymap.js +42 -6
- package/src/js/tcmd/core/operations.js +813 -48
- package/src/js/tcmd/core/os.js +29 -3
- package/src/js/tcmd/core/pane.js +33 -8
- package/src/js/tcmd/core/panel.js +280 -104
- 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/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +498 -200
- 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/index.js +0 -1
- package/src/js/tcmd/tui/app.js +118 -60
- package/src/js/tcmd/tui/catalogue.js +238 -42
- package/src/js/tcmd/tui/index.js +18 -19
- 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 +32 -2
- package/src/js/tcmd/vendor/bzip2.js +276 -0
- package/src/js/tcmd/web/app.js +225 -41
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +81 -38
- package/src/js/tcmd/web/effects.js +223 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/http.js +13 -0
- package/src/js/tcmd/web/index.js +2 -2
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/opfs.js +297 -30
- package/src/js/tcmd/web/seed.js +4 -4
- package/src/js/tcmd/web/server.js +124 -21
- package/src/js/tcmd/web/theme.js +4 -0
- package/tinyjs.json +16 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- 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/frame.js +0 -269
- package/src/js/tcmd/tui/help.js +0 -32
|
@@ -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
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-22
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/picker
|
|
7
|
+
// The command panel's file picker: the private pane the panel browses while a
|
|
8
|
+
// `dest`/`path` argument is being filled. The picker owns its editable path
|
|
9
|
+
// text, the directory that text resolves against, the visible listing and the
|
|
10
|
+
// queue that keeps fast typing from landing an older navigation after a newer
|
|
11
|
+
// one. State cells live on the session (see `core/session`); this module holds
|
|
12
|
+
// the path rules and the row shaping so `core/panel` stays about argument
|
|
13
|
+
// binding.
|
|
14
|
+
|
|
15
|
+
import { basename, join, normalize, parent } from "./path.js"
|
|
16
|
+
|
|
17
|
+
// Function: pickerAbsolutePath
|
|
18
|
+
// Resolves the picker's editable path text: `~`, an absolute path, or a path
|
|
19
|
+
// relative to `base` (the directory the picker was opened from).
|
|
20
|
+
function pickerAbsolutePath(text, base = "/", home = "/") {
|
|
21
|
+
const raw = `${text ?? ""}`
|
|
22
|
+
if (!raw) return normalize(base || "/")
|
|
23
|
+
if (raw === "~") return normalize(home || "/")
|
|
24
|
+
if (raw.startsWith("~/")) return join(home, raw.slice(2))
|
|
25
|
+
if (raw.startsWith("/")) return normalize(raw)
|
|
26
|
+
return join(base || "/", raw)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Function: pickerDirectoryText
|
|
30
|
+
// The path input's display form: a trailing slash marks a directory, so typing
|
|
31
|
+
// after it starts a child name (the same affordance a shell prompt gives).
|
|
32
|
+
function pickerDirectoryText(path) {
|
|
33
|
+
const normalized = normalize(path)
|
|
34
|
+
return normalized === "/" ? "/" : `${normalized}/`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Function: resolvePickerPath
|
|
38
|
+
// Interprets the editable path text as `{ directory, query }`: an existing
|
|
39
|
+
// directory lists its contents, an existing file keeps its parent listing with
|
|
40
|
+
// the file as the filter, and a missing path walks up to the closest existing
|
|
41
|
+
// directory while the dropped segment becomes the filter. This is what makes
|
|
42
|
+
// typing `/home/do` show the matching entries under `/home`.
|
|
43
|
+
async function resolvePickerPath(fs, text, base = "/", home = "/") {
|
|
44
|
+
const raw = `${text ?? ""}`
|
|
45
|
+
const absolute = pickerAbsolutePath(raw, base, home)
|
|
46
|
+
const stats = await fs.stat(absolute)
|
|
47
|
+
if (stats?.isDir) return { directory: absolute, query: "" }
|
|
48
|
+
if (stats) return { directory: parent(absolute) ?? "/", query: basename(absolute) }
|
|
49
|
+
|
|
50
|
+
const trailing = raw.endsWith("/")
|
|
51
|
+
let directory = trailing ? absolute : parent(absolute) ?? "/"
|
|
52
|
+
const rest = trailing ? [] : [basename(absolute)]
|
|
53
|
+
while (directory !== "/") {
|
|
54
|
+
if ((await fs.stat(directory))?.isDir) break
|
|
55
|
+
rest.unshift(basename(directory))
|
|
56
|
+
directory = parent(directory) ?? "/"
|
|
57
|
+
}
|
|
58
|
+
return { directory, query: rest[0] ?? "" }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Function: pickerVisibleEntries
|
|
62
|
+
// The entries the picker lists for a mode: a destination picker lists
|
|
63
|
+
// directories only, so every row is a valid choice; a file/any picker keeps the
|
|
64
|
+
// listing intact, where a directory row descends.
|
|
65
|
+
function pickerVisibleEntries(entries, mode) {
|
|
66
|
+
return mode === "dir" ? (entries ?? []).filter((entry) => entry.isDir) : entries ?? []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Function: pickerRowList
|
|
70
|
+
// Flattens the picker's visible listing into the rows the picker renders. The
|
|
71
|
+
// `..` parent navigation row stays at the top when filtering and is omitted at
|
|
72
|
+
// the filesystem root; entry rows keep the listing details (size, mode, link
|
|
73
|
+
// target) so they render exactly like a file panel.
|
|
74
|
+
function pickerRowList(entries, path) {
|
|
75
|
+
const rows = []
|
|
76
|
+
const parentPath = parent(path)
|
|
77
|
+
if (parentPath) {
|
|
78
|
+
rows.push({
|
|
79
|
+
type: "parent",
|
|
80
|
+
key: `parent:${path}`,
|
|
81
|
+
name: "..",
|
|
82
|
+
path: parentPath,
|
|
83
|
+
isDir: true,
|
|
84
|
+
isSymlink: false,
|
|
85
|
+
isDevice: false,
|
|
86
|
+
isParent: true,
|
|
87
|
+
target: null,
|
|
88
|
+
size: 0,
|
|
89
|
+
mode: 0,
|
|
90
|
+
ext: null,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
for (const entry of entries ?? []) {
|
|
94
|
+
rows.push({ ...entry, type: "entry", key: entry.path })
|
|
95
|
+
}
|
|
96
|
+
return rows
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Function: createPickerPathQueue
|
|
100
|
+
// Creates the picker's path-resolution queue for one session. `invalidate`
|
|
101
|
+
// drops any queued work before an explicit navigation or close; `schedule`
|
|
102
|
+
// resolves one path text and applies the resulting directory and filter. Work
|
|
103
|
+
// is serialized so fast typing cannot land an older navigation after a newer
|
|
104
|
+
// one, and a token drops every intermediate keystroke.
|
|
105
|
+
function createPickerPathQueue(session) {
|
|
106
|
+
let version = 0
|
|
107
|
+
let queue = Promise.resolve()
|
|
108
|
+
return {
|
|
109
|
+
invalidate() {
|
|
110
|
+
version += 1
|
|
111
|
+
},
|
|
112
|
+
schedule(text) {
|
|
113
|
+
if (!session.pickerOpen.value) return
|
|
114
|
+
const token = ++version
|
|
115
|
+
queue = queue
|
|
116
|
+
.then(async () => {
|
|
117
|
+
if (token !== version || !session.pickerOpen.value) return
|
|
118
|
+
const base = session.pickerBasePath.value || session.pickerPane.path.value || "/"
|
|
119
|
+
const { directory, query } = await resolvePickerPath(
|
|
120
|
+
session.fs,
|
|
121
|
+
text,
|
|
122
|
+
base,
|
|
123
|
+
session.fs.home?.() ?? "/",
|
|
124
|
+
)
|
|
125
|
+
if (token !== version || session.pickerPathText.value !== text) return
|
|
126
|
+
if (session.pickerPane.path.value !== directory) {
|
|
127
|
+
await session.pickerPane.navigate(directory, { record: false })
|
|
128
|
+
}
|
|
129
|
+
if (token !== version || session.pickerPathText.value !== text) return
|
|
130
|
+
session.pickerQuery.set(query)
|
|
131
|
+
const rows = session.pickerRows.value
|
|
132
|
+
const firstMatch = query ? rows.findIndex((row) => row.type === "entry") : 0
|
|
133
|
+
session.panelRowIndex.set(firstMatch >= 0 ? firstMatch : 0)
|
|
134
|
+
})
|
|
135
|
+
.catch((error) => {
|
|
136
|
+
// Keep the queue alive after an unreadable path; the picker
|
|
137
|
+
// keeps the last listing and the command bar reports what
|
|
138
|
+
// failed.
|
|
139
|
+
if (token === version) {
|
|
140
|
+
session.notice?.set({ kind: "warning", text: `${error?.message ?? error}` })
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
createPickerPathQueue,
|
|
149
|
+
pickerAbsolutePath,
|
|
150
|
+
pickerDirectoryText,
|
|
151
|
+
pickerRowList,
|
|
152
|
+
pickerVisibleEntries,
|
|
153
|
+
resolvePickerPath,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// EOF
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-16
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/preview
|
|
7
|
+
// Preview classification and payload helpers, shared by the session's preview
|
|
8
|
+
// effect and by the commands that need to know how an entry previews — Enter
|
|
9
|
+
// opens a text file in `$EDITOR` and everything else in the fullscreen
|
|
10
|
+
// inspector (see `core/commands`). Pure and renderer-free: `previewKind` maps
|
|
11
|
+
// a resolved extension to a preview kind, `previewSource` resolves what a
|
|
12
|
+
// symlink previews *through* (and what a compressed file decompresses *to*),
|
|
13
|
+
// and `hasBinaryByte` is the content sniff that turns a text head into a
|
|
14
|
+
// metadata card.
|
|
15
|
+
|
|
16
|
+
import { basename, extname } from "./path.js"
|
|
17
|
+
|
|
18
|
+
// Constant: PREVIEW_BYTES / SNIFF_BYTES
|
|
19
|
+
// Bytes of a text file the inspector reads for its inline body — the head
|
|
20
|
+
// budget every filesystem adapter defaults to — and the shorter opening run
|
|
21
|
+
// `hasBinaryByte` sniffs to call a body binary.
|
|
22
|
+
const PREVIEW_BYTES = 65536
|
|
23
|
+
const SNIFF_BYTES = 2048
|
|
24
|
+
|
|
25
|
+
// Constant: IMAGE_EXTS / MEDIA_EXTS
|
|
26
|
+
// Extensions with a dedicated (non-text) preview: they render as a metadata
|
|
27
|
+
// card, never as an editable text body.
|
|
28
|
+
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"]
|
|
29
|
+
const MEDIA_EXTS = ["mp3", "wav", "ogg", "flac", "mp4", "webm", "mkv", "mov", "pdf"]
|
|
30
|
+
|
|
31
|
+
// Constant: COMPRESSION_EXTS
|
|
32
|
+
// Single-file compression formats the inspector decompresses before sniffing
|
|
33
|
+
// the body. The compressed entry keeps its own extension in the header; the
|
|
34
|
+
// name inside (`notes.txt` in `notes.txt.gz`) supplies the kind, the syntax
|
|
35
|
+
// filetype and the badge's inner label.
|
|
36
|
+
const COMPRESSION_EXTS = new Map([
|
|
37
|
+
["gz", "gzip"],
|
|
38
|
+
["bz2", "bzip2"],
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
// Function: compressionOf
|
|
42
|
+
// Decompressor for a resolved extension ("gzip"/"bzip2"), or null when the
|
|
43
|
+
// entry is not compressed.
|
|
44
|
+
function compressionOf(ext) {
|
|
45
|
+
return ext ? COMPRESSION_EXTS.get(ext) ?? null : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Function: previewKind
|
|
49
|
+
// Preview classification for a resolved file extension; directories are not
|
|
50
|
+
// previewed (the caller passes their `null` ext). Takes the extension rather
|
|
51
|
+
// than the entry so a symlink classifies as what it points at.
|
|
52
|
+
function previewKind(ext) {
|
|
53
|
+
if (ext && IMAGE_EXTS.includes(ext)) return "image"
|
|
54
|
+
if (ext && MEDIA_EXTS.includes(ext)) return "media"
|
|
55
|
+
return "text"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Function: hasBinaryByte
|
|
59
|
+
// Content sniff: a text file's head carries no control byte in its opening
|
|
60
|
+
// run, so a hit here means the body is binary and previews as a card.
|
|
61
|
+
function hasBinaryByte(text) {
|
|
62
|
+
for (let i = 0; i < SNIFF_BYTES && i < text.length; i++) {
|
|
63
|
+
const code = text.charCodeAt(i)
|
|
64
|
+
if (code < 9 || (code > 13 && code < 32)) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Function: previewSource
|
|
70
|
+
// What the preview renders *through* a symlink, and what a compressed file
|
|
71
|
+
// decompresses to: `title` names the traversal (`notes -> ../real/notes`) so
|
|
72
|
+
// it is visible in the header; `ext` is the entry's (target's) own extension;
|
|
73
|
+
// `innerName`/`innerExt` strip a `.gz`/`.bz2` suffix so `notes.txt.gz`
|
|
74
|
+
// previews with the kind, filetype and badge of `notes.txt`; `compression` is
|
|
75
|
+
// the decompressor the session applies, or null. Plain entries pass through
|
|
76
|
+
// with `innerName === name` and `innerExt === ext`.
|
|
77
|
+
function previewSource(entry) {
|
|
78
|
+
const target = entry?.isSymlink && entry.target ? `${entry.target}` : null
|
|
79
|
+
const name = target ? basename(target) : basename(entry?.path ?? "")
|
|
80
|
+
const ext = extname(name) ?? entry?.ext ?? null
|
|
81
|
+
const compression = compressionOf(ext)
|
|
82
|
+
const innerName = compression ? name.slice(0, name.lastIndexOf(".")) : name
|
|
83
|
+
const innerExt = compression ? extname(innerName) : ext
|
|
84
|
+
return {
|
|
85
|
+
title: target ? `${basename(entry.path)} -> ${target}` : name,
|
|
86
|
+
ext,
|
|
87
|
+
innerName,
|
|
88
|
+
innerExt,
|
|
89
|
+
compression,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export {
|
|
94
|
+
compressionOf,
|
|
95
|
+
hasBinaryByte,
|
|
96
|
+
PREVIEW_BYTES,
|
|
97
|
+
previewKind,
|
|
98
|
+
previewSource,
|
|
99
|
+
SNIFF_BYTES,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// EOF
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// Created: 2026-09-27
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/core/script
|
|
7
|
-
// The scripting language
|
|
8
|
-
// line
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// The TinyCmd scripting language: line tokenizer, operation parser and runner.
|
|
8
|
+
// A line starts with an operation name (or the `!` alias for `run`); arguments
|
|
9
|
+
// may be filesets ($A, $B, $FILE), quoted paths, `~` or paths relative to the
|
|
10
|
+
// active pane. The command panel runs operations structurally and delegates to
|
|
11
|
+
// the same `runLine` through `runOperation`.
|
|
12
12
|
|
|
13
|
-
import { getOperation,
|
|
13
|
+
import { getOperation, runOperation } from "./operations.js"
|
|
14
14
|
|
|
15
15
|
// Function: tokenize
|
|
16
16
|
// Whitespace tokenizer with single/double quotes and backslash escapes.
|
|
@@ -93,94 +93,6 @@ async function runLine(session, line, options = {}) {
|
|
|
93
93
|
return runOperation(session, parsed.op, parsed.args, { ...options, line })
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
// Palette rows for scripting mode: the session's available filesets first,
|
|
98
|
-
// then every operation, then matching entries of the active pane (path
|
|
99
|
-
// completion). Rows carry the full query replacement `completion` for Tab.
|
|
100
|
-
function scriptItems(query, filesets, context = {}) {
|
|
101
|
-
const text = `${query ?? ""}`
|
|
102
|
-
const q = text.trim().toLowerCase()
|
|
103
|
-
const head = q.split(/\s+/)[0] ?? ""
|
|
104
|
-
const rows = []
|
|
105
|
-
const typingFileset = lastToken(q).startsWith("$")
|
|
106
|
-
for (const set of filesets ?? []) {
|
|
107
|
-
const id = set.id.toLowerCase()
|
|
108
|
-
if (head && !id.includes(head) && !q.includes(id) && !typingFileset) continue
|
|
109
|
-
rows.push({
|
|
110
|
-
id: set.id,
|
|
111
|
-
title: set.id,
|
|
112
|
-
kind: "fileset",
|
|
113
|
-
detail: `${set.count} file${set.count === 1 ? "" : "s"}`,
|
|
114
|
-
completion: completeFileset(text, set.id),
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
for (const op of getOperations()) {
|
|
118
|
-
if (op.hidden) continue
|
|
119
|
-
const names = [op.name, ...(op.aliases ?? [])]
|
|
120
|
-
const matchesName = !head || names.some((name) => name.toLowerCase().includes(head))
|
|
121
|
-
if (!matchesName && !op.signature.toLowerCase().includes(q)) continue
|
|
122
|
-
rows.push({
|
|
123
|
-
id: `op:${op.name}`,
|
|
124
|
-
title: op.name,
|
|
125
|
-
kind: "op",
|
|
126
|
-
detail: op.signature,
|
|
127
|
-
description: op.description,
|
|
128
|
-
completion: q.includes(" ") ? text.replace(/^\S+/, op.name) : `${op.name} `,
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
const last = lastToken(q)
|
|
132
|
-
const token = lastToken(text)
|
|
133
|
-
if (token && !token.startsWith("$")) {
|
|
134
|
-
const at = token.lastIndexOf("/")
|
|
135
|
-
const dirPart = at >= 0 ? token.slice(0, at + 1) : ""
|
|
136
|
-
const prefix = (at >= 0 ? token.slice(at + 1) : token).toLowerCase()
|
|
137
|
-
for (const entry of context.entries ?? []) {
|
|
138
|
-
if (!entry.name.toLowerCase().startsWith(prefix)) continue
|
|
139
|
-
const name = entry.isDir ? `${entry.name}/` : entry.name
|
|
140
|
-
rows.push({
|
|
141
|
-
id: `path:${entry.path}`,
|
|
142
|
-
title: name,
|
|
143
|
-
kind: "path",
|
|
144
|
-
detail: entry.isDir ? "dir" : humanSize(entry.size),
|
|
145
|
-
completion: replaceLastToken(text, `${dirPart}${name}`),
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return rows
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Function: lastToken
|
|
153
|
-
// The last whitespace-separated token, case preserved.
|
|
154
|
-
function lastToken(text) {
|
|
155
|
-
const match = /(\S+)\s*$/.exec(`${text ?? ""}`)
|
|
156
|
-
return match ? match[1] : ""
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Function: completionToken
|
|
160
|
-
// For a query whose last token contains "/", the directory part to read and
|
|
161
|
-
// the name prefix being completed; null when the token is not a path.
|
|
162
|
-
function completionToken(query) {
|
|
163
|
-
const token = lastToken(query)
|
|
164
|
-
if (!token || token.startsWith("$")) return null
|
|
165
|
-
const at = token.lastIndexOf("/")
|
|
166
|
-
if (at < 0) return null
|
|
167
|
-
return { dir: token.slice(0, at) || "/", prefix: token.slice(at + 1) }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Function: replaceLastToken
|
|
171
|
-
// Replaces the (possibly empty) last whitespace-separated token.
|
|
172
|
-
function replaceLastToken(text, replacement) {
|
|
173
|
-
const match = /(\S*)\s*$/.exec(text)
|
|
174
|
-
return `${text.slice(0, match.index)}${replacement}`
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Function: completeFileset
|
|
178
|
-
// Replaces the `$…` token being typed, or appends the fileset.
|
|
179
|
-
function completeFileset(text, id) {
|
|
180
|
-
if (/\$\w*$/.test(text.trimEnd())) return replaceLastToken(text, id)
|
|
181
|
-
return `${text} ${id}`.trim()
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export { completionToken, parseLine, runLine, scriptItems, tokenize }
|
|
96
|
+
export { parseLine, runLine, tokenize }
|
|
185
97
|
|
|
186
98
|
// EOF
|
|
@@ -26,6 +26,14 @@ function currentEntry(entries, cursor) {
|
|
|
26
26
|
return entries[cursor]
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Function: indexOfPath
|
|
30
|
+
// The index of the entry whose path is `path`, or -1. Navigations that must
|
|
31
|
+
// land on a known element use it: the directory `up` stepped out of, the
|
|
32
|
+
// current directory in a mirror's listing.
|
|
33
|
+
function indexOfPath(entries, path) {
|
|
34
|
+
return entries.findIndex((entry) => entry.path === path)
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
// Function: toggleMark
|
|
30
38
|
// Returns a new Set with `path` toggled in `marks`.
|
|
31
39
|
function toggleMark(marks, path) {
|
|
@@ -38,6 +46,6 @@ function toggleMark(marks, path) {
|
|
|
38
46
|
return next
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
export { clampCursor, currentEntry, moveCursor, toggleMark }
|
|
49
|
+
export { clampCursor, currentEntry, indexOfPath, moveCursor, toggleMark }
|
|
42
50
|
|
|
43
51
|
// EOF
|