@ssebastien/tcmd 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +234 -2
  2. package/bin/tcmd +7 -0
  3. package/package.json +37 -5
  4. package/src/js/tcmd/components/App.html +28 -0
  5. package/src/js/tcmd/components/App.js +355 -0
  6. package/src/js/tcmd/components/App.xml +98 -0
  7. package/src/js/tcmd/components/BookmarkList.html +12 -0
  8. package/src/js/tcmd/components/BookmarkList.js +73 -0
  9. package/src/js/tcmd/components/BookmarkList.xml +18 -0
  10. package/src/js/tcmd/components/BookmarkRow.html +5 -0
  11. package/src/js/tcmd/components/BookmarkRow.js +40 -0
  12. package/src/js/tcmd/components/BookmarkRow.xml +10 -0
  13. package/src/js/tcmd/components/Breadcrumbs.js +27 -0
  14. package/src/js/tcmd/components/CommandArgRow.js +90 -0
  15. package/src/js/tcmd/components/CommandArgRow.xml +5 -0
  16. package/src/js/tcmd/components/CommandBar.html +4 -0
  17. package/src/js/tcmd/components/CommandBar.js +126 -0
  18. package/src/js/tcmd/components/CommandBar.xml +4 -0
  19. package/src/js/tcmd/components/CommandItem.html +3 -0
  20. package/src/js/tcmd/components/CommandItem.js +78 -0
  21. package/src/js/tcmd/components/CommandItem.xml +6 -0
  22. package/src/js/tcmd/components/CommandPalette.html +7 -0
  23. package/src/js/tcmd/components/CommandPalette.js +58 -0
  24. package/src/js/tcmd/components/CommandPalette.xml +16 -0
  25. package/src/js/tcmd/components/CommandPanel.js +98 -0
  26. package/src/js/tcmd/components/CommandPanel.xml +35 -0
  27. package/src/js/tcmd/components/Confirm.html +3 -0
  28. package/src/js/tcmd/components/Confirm.js +36 -0
  29. package/src/js/tcmd/components/Confirm.xml +7 -0
  30. package/src/js/tcmd/components/Crumb.html +4 -0
  31. package/src/js/tcmd/components/Crumb.js +48 -0
  32. package/src/js/tcmd/components/Crumb.xml +4 -0
  33. package/src/js/tcmd/components/EntryDetail.html +15 -0
  34. package/src/js/tcmd/components/EntryDetail.js +194 -0
  35. package/src/js/tcmd/components/EntryDetail.xml +16 -0
  36. package/src/js/tcmd/components/FileList.html +1 -0
  37. package/src/js/tcmd/components/FileList.js +41 -0
  38. package/src/js/tcmd/components/FileList.xml +7 -0
  39. package/src/js/tcmd/components/FilePreview.html +5 -0
  40. package/src/js/tcmd/components/FilePreview.js +110 -0
  41. package/src/js/tcmd/components/FilePreview.xml +10 -0
  42. package/src/js/tcmd/components/FileRow.html +5 -0
  43. package/src/js/tcmd/components/FileRow.js +135 -0
  44. package/src/js/tcmd/components/FileRow.xml +10 -0
  45. package/src/js/tcmd/components/FilterBar.html +6 -0
  46. package/src/js/tcmd/components/FilterBar.js +50 -0
  47. package/src/js/tcmd/components/FilterBar.xml +6 -0
  48. package/src/js/tcmd/components/Help.xml +11 -0
  49. package/src/js/tcmd/components/PaletteRow.html +3 -0
  50. package/src/js/tcmd/components/PaletteRow.js +43 -0
  51. package/src/js/tcmd/components/PaletteRow.xml +4 -0
  52. package/src/js/tcmd/components/RowList.html +1 -0
  53. package/src/js/tcmd/components/RowList.xml +1 -0
  54. package/src/js/tcmd/components/Summary.html +3 -0
  55. package/src/js/tcmd/components/Summary.js +77 -0
  56. package/src/js/tcmd/components/Summary.xml +3 -0
  57. package/src/js/tcmd/components/bind.js +20 -0
  58. package/src/js/tcmd/components/format.js +155 -0
  59. package/src/js/tcmd/components/icons.js +185 -0
  60. package/src/js/tcmd/components/index.js +41 -0
  61. package/src/js/tcmd/components/rows.js +212 -0
  62. package/src/js/tcmd/core/bookmarks.js +175 -0
  63. package/src/js/tcmd/core/commands.js +930 -0
  64. package/src/js/tcmd/core/directory.js +194 -0
  65. package/src/js/tcmd/core/entries.js +88 -0
  66. package/src/js/tcmd/core/fs.js +266 -0
  67. package/src/js/tcmd/core/index.js +39 -0
  68. package/src/js/tcmd/core/keymap.js +60 -0
  69. package/src/js/tcmd/core/operations.js +453 -0
  70. package/src/js/tcmd/core/os.js +128 -0
  71. package/src/js/tcmd/core/pane.js +74 -0
  72. package/src/js/tcmd/core/panel.js +383 -0
  73. package/src/js/tcmd/core/path.js +57 -0
  74. package/src/js/tcmd/core/script.js +186 -0
  75. package/src/js/tcmd/core/selection.js +43 -0
  76. package/src/js/tcmd/core/session.js +405 -0
  77. package/src/js/tcmd/index.js +53 -0
  78. package/src/js/tcmd/tui/app.js +140 -0
  79. package/src/js/tcmd/tui/catalogue.js +300 -0
  80. package/src/js/tcmd/tui/frame.js +269 -0
  81. package/src/js/tcmd/tui/help.js +32 -0
  82. package/src/js/tcmd/tui/index.js +104 -0
  83. package/src/js/tcmd/tui/theme.js +177 -0
  84. package/src/js/tcmd/vendor/fzy.js +243 -0
  85. package/src/js/tcmd/web/app.js +154 -0
  86. package/src/js/tcmd/web/catalogue.js +159 -0
  87. package/src/js/tcmd/web/header.html +1 -0
  88. package/src/js/tcmd/web/header.js +38 -0
  89. package/src/js/tcmd/web/http.js +79 -0
  90. package/src/js/tcmd/web/index.js +22 -0
  91. package/src/js/tcmd/web/main.js +151 -0
  92. package/src/js/tcmd/web/opfs.js +282 -0
  93. package/src/js/tcmd/web/seed.js +90 -0
  94. package/src/js/tcmd/web/server.js +191 -0
  95. package/src/js/tcmd/web/theme.js +23 -0
@@ -0,0 +1,194 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-27
5
+
6
+ // Module: tcmd/core/directory
7
+ // A Directory is the per-pane state: the current path, the entries read from
8
+ // the filesystem, the derived (filtered + sorted) view, the cursor, the disk
9
+ // figures, the selection buffer and the navigation history (back/forward).
10
+ // A pane owns exactly one directory — the
11
+ // former multi-tab model is gone — and the pane's cells proxy it directly.
12
+
13
+ import { cell, derived, effect } from "select/state/cells"
14
+ import { viewEntries } from "./entries.js"
15
+ import { clampCursor, currentEntry, toggleMark } from "./selection.js"
16
+ import { SEP, parent as parentPath, split, prefix } from "./path.js"
17
+
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
+ // Function: createDirectory
36
+ // Creates the directory state for one pane, bound to a filesystem adapter.
37
+ //
38
+ // Example:
39
+ // ```javascript
40
+ // const dir = createDirectory({ fs: createNodeFs(), path: "/home" })
41
+ // await dir.refresh()
42
+ // dir.entries.value // sorted, filtered FileEntry[]
43
+ // ```
44
+ function createDirectory({ fs, path: initialPath }) {
45
+ const dir = { fs }
46
+ dir.path = cell(initialPath)
47
+ dir.rawEntries = cell([])
48
+ dir.loading = cell(false)
49
+ dir.error = cell(null)
50
+ dir.filter = cell("")
51
+ dir.sort = cell({ key: "name", dir: 1 })
52
+ dir.entries = derived([dir.rawEntries, dir.filter, dir.sort], ([raw, filter, sort]) =>
53
+ viewEntries(raw, filter, sort),
54
+ )
55
+ dir.cursor = cell(0)
56
+ 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
+ // Selection buffer for this directory (the pane's marked paths).
63
+ dir.marked = cell(new Set())
64
+ // Navigation history: visited paths with the current one at
65
+ // `history.index`, so back/forward walk the stack like a browser.
66
+ dir.history = { stack: [initialPath], index: 0 }
67
+
68
+ // A changing filter re-ranks the listing: snap the cursor back to the top
69
+ // match. Disposed with the directory (see `dispose`).
70
+ const stopFilterWatch = effect([dir.filter], () => dir.cursor.set(0))
71
+
72
+ // Re-stat the cursor entry on movement; cache by path so a re-render that
73
+ // does not move the cursor does not hit the filesystem again.
74
+ const stopCursorWatch = effect([dir.entries, dir.cursor], () => {
75
+ const entry = currentEntry(dir.entries.value, dir.cursor.value)
76
+ if (!entry || entry.path === cursorStatPath) return
77
+ cursorStatPath = entry.path
78
+ dir.cursorStat.set(entry)
79
+ void enrichEntry(fs, entry).then((enriched) => {
80
+ if (cursorStatPath === entry.path) dir.cursorStat.set(enriched)
81
+ })
82
+ })
83
+
84
+ // Function: refresh
85
+ // Re-reads the directory at `dir.path`, updating `rawEntries` and clamping
86
+ // the cursor. Sets `loading` while in flight; failures land in `error`.
87
+ dir.refresh = async () => {
88
+ dir.loading.set(true)
89
+ dir.error.set(null)
90
+ try {
91
+ dir.rawEntries.set(await fs.readDir(dir.path.value))
92
+ dir.cursor.set(clampCursor(dir.cursor.value, dir.entries.value.length))
93
+ dir.disk.set(await fs.disk(dir.path.value))
94
+ } catch (error) {
95
+ dir.error.set(`${error?.message ?? error}`)
96
+ } finally {
97
+ dir.loading.set(false)
98
+ }
99
+ }
100
+
101
+ // Function: apply
102
+ // The actual move to `path`: sets the path, resets filter, marks and
103
+ // cursor, then refreshes. History-neutral — navigate/back/forward record
104
+ // their own steps around it.
105
+ const apply = async (path) => {
106
+ dir.path.set(path)
107
+ dir.filter.set("")
108
+ dir.marked.set(new Set())
109
+ dir.cursor.set(0)
110
+ await dir.refresh()
111
+ }
112
+
113
+ // Function: navigate
114
+ // Moves the directory to `path` (resetting filter, marks and cursor),
115
+ // records the step in the history and refreshes. Re-navigating to the
116
+ // current path does not duplicate the entry.
117
+ dir.navigate = async (path) => {
118
+ const history = dir.history
119
+ if (history.stack[history.index] !== path) {
120
+ // A new navigation truncates the forward branch, like a browser.
121
+ history.stack.length = history.index + 1
122
+ history.stack.push(path)
123
+ history.index = history.stack.length - 1
124
+ }
125
+ await apply(path)
126
+ }
127
+
128
+ // Function: back
129
+ // Steps one entry back in the navigation history; no-op at its start.
130
+ dir.back = async () => {
131
+ const history = dir.history
132
+ if (history.index <= 0) return
133
+ history.index -= 1
134
+ await apply(history.stack[history.index])
135
+ }
136
+
137
+ // Function: forward
138
+ // Steps one entry forward in the navigation history; no-op at its end.
139
+ dir.forward = async () => {
140
+ const history = dir.history
141
+ if (history.index >= history.stack.length - 1) return
142
+ history.index += 1
143
+ await apply(history.stack[history.index])
144
+ }
145
+
146
+ // Function: up
147
+ // Navigates to the parent directory; no-op at the filesystem root.
148
+ dir.up = async () => {
149
+ const target = parentPath(dir.path.value)
150
+ if (target) await dir.navigate(target)
151
+ }
152
+
153
+ // Function: clearFilter
154
+ // Removes the directory's filter, restoring the full listing.
155
+ dir.clearFilter = () => dir.filter.set("")
156
+
157
+ // Function: open
158
+ // Opens the entry under the cursor: descends into directories.
159
+ dir.open = async () => {
160
+ const entry = currentEntry(dir.entries.value, dir.cursor.value)
161
+ if (entry?.isDir) await dir.navigate(entry.path)
162
+ }
163
+
164
+ // Function: currentEntry
165
+ // The entry under the cursor, or `undefined` for empty listings.
166
+ dir.currentEntry = () => currentEntry(dir.entries.value, dir.cursor.value)
167
+
168
+ // Function: toggleMark
169
+ // Marks or unmarks the entry under the cursor.
170
+ dir.toggleMark = () => {
171
+ const entry = dir.currentEntry()
172
+ if (entry) dir.marked.set(toggleMark(dir.marked.value, entry.path))
173
+ }
174
+
175
+ // Function: segments
176
+ // Derived breadcrumb steps for the current path: [{ name, path }], starting
177
+ // with the filesystem root.
178
+ dir.segments = derived([dir.path], ([p]) => {
179
+ const parts = split(p)
180
+ const steps = parts.map((name, i) => ({ name, path: prefix(p, i + 1) }))
181
+ return [{ name: SEP, path: SEP }, ...steps]
182
+ })
183
+
184
+ // Function: dispose
185
+ // Releases the directory's reactive watchers.
186
+ dir.dispose = () => {
187
+ stopFilterWatch()
188
+ stopCursorWatch()
189
+ }
190
+
191
+ return dir
192
+ }
193
+
194
+ export { createDirectory }
@@ -0,0 +1,88 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/entries
7
+ // Pure operations over file entries: fuzzy filtering and sorting. Entries are
8
+ // plain objects shaped by the filesystem adapter (see core/fs). Matching uses
9
+ // the vendored fzy scorer (see vendor/fzy): a query matches when its characters
10
+ // appear in order, and matches are ranked by score. Directories stay grouped
11
+ // first, then by the configured sort key when scores tie.
12
+
13
+ import { hasMatch, score } from "../vendor/fzy.js"
14
+
15
+ // Function: normalizeQuery
16
+ // Trims the raw filter text; an empty/whitespace query means "no filter".
17
+ function normalizeQuery(query) {
18
+ return `${query ?? ""}`.trim()
19
+ }
20
+
21
+ // Function: filterEntries
22
+ // Keeps entries whose name fuzzily matches `query` (subsequence,
23
+ // case-insensitive), in listing order. An empty query keeps everything.
24
+ function filterEntries(entries, query) {
25
+ const q = normalizeQuery(query)
26
+ if (!q) return entries
27
+ const res = []
28
+ for (let i = 0; i < entries.length; i++) {
29
+ if (hasMatch(q, entries[i].name)) res.push(entries[i])
30
+ }
31
+ return res
32
+ }
33
+
34
+ // Function: compareEntries
35
+ // Orders two entries within the same group (dir/file) by `sort.key`
36
+ // ("name" | "size" | "mtime") and `sort.dir` (1 ascending, -1 descending).
37
+ function compareEntries(a, b, sort) {
38
+ const key = sort?.key ?? "name"
39
+ const dir = sort?.dir ?? 1
40
+ let cmp = 0
41
+ if (key === "name") cmp = a.name.localeCompare(b.name)
42
+ else if (key === "size") cmp = (a.size ?? 0) - (b.size ?? 0)
43
+ else if (key === "mtime") cmp = (a.mtimeMs ?? 0) - (b.mtimeMs ?? 0)
44
+ return cmp * dir
45
+ }
46
+
47
+ // Function: rankEntries
48
+ // Fuzzily matches `entries` against `query` and ranks them: directories first,
49
+ // then by descending fzy score, ties broken by `sort`. Entries that do not
50
+ // match are dropped.
51
+ function rankEntries(entries, query, sort) {
52
+ const q = normalizeQuery(query)
53
+ const ranked = []
54
+ for (let i = 0; i < entries.length; i++) {
55
+ const entry = entries[i]
56
+ if (!hasMatch(q, entry.name)) continue
57
+ ranked.push({ entry, score: score(q, entry.name) })
58
+ }
59
+ ranked.sort((a, b) => {
60
+ if (a.entry.isDir !== b.entry.isDir) return a.entry.isDir ? -1 : 1
61
+ if (a.score !== b.score) return b.score - a.score
62
+ return compareEntries(a.entry, b.entry, sort)
63
+ })
64
+ return ranked.map((match) => match.entry)
65
+ }
66
+
67
+ // Function: sortEntries
68
+ // Sorts a copy of `entries`: directories first, then by `sort.key`.
69
+ function sortEntries(entries, sort) {
70
+ const sorted = entries.slice()
71
+ sorted.sort((a, b) => {
72
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
73
+ return compareEntries(a, b, sort)
74
+ })
75
+ return sorted
76
+ }
77
+
78
+ // Function: viewEntries
79
+ // The listing a directory shows: `sortEntries` when there is no filter,
80
+ // otherwise the fzy-ranked matches of `query` (see `rankEntries`).
81
+ function viewEntries(entries, query, sort) {
82
+ if (!normalizeQuery(query)) return sortEntries(entries, sort)
83
+ return rankEntries(entries, query, sort)
84
+ }
85
+
86
+ export { filterEntries, rankEntries, sortEntries, viewEntries }
87
+
88
+ // EOF
@@ -0,0 +1,266 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/fs
7
+ // Filesystem adapter interface plus a Node/Bun implementation. The core only
8
+ // talks to this interface, so a web build can swap in OPFS or HTTP backends.
9
+ //
10
+ // An adapter provides:
11
+ // - `readDir(path)`: Promise<FileEntry-like[]> - directory listing with stat info
12
+ // - `stat(path)`: Promise<{ isDir, isSymlink, size, mtimeMs, ctimeMs, mode, uid,
13
+ // gid } | null>
14
+ // - `isDir(path)`: Promise<boolean>
15
+ // - `readTextHead(path, maxBytes)`: Promise<string> - leading bytes as text
16
+ // - `du(path)`: Promise<number> - recursive size in bytes
17
+ // - `disk(path)`: Promise<{ free, total } | null> - filesystem capacity
18
+ // - `copy(from, to)`, `move(from, to)`: Promise<void> - files or directories
19
+ // - `trash(paths)`, `erase(paths)`: Promise<void> - delete, with/without trash
20
+ // - `touch(path)`: Promise<void> - create or bump mtime
21
+ // - `home()`, `cwd()`: string - well-known directories
22
+
23
+ import fs from "node:fs/promises"
24
+ import nodeFs from "node:fs"
25
+ import nodeOs from "node:os"
26
+ import nodePath from "node:path"
27
+ import { extname, join as pathJoin } from "./path.js"
28
+
29
+ // Owner-name caches, filled lazily from /etc/passwd and /etc/group. Browsers
30
+ // and non-POSIX hosts never populate them (the adapters there omit `owner`).
31
+ let passwdNames = null
32
+ let groupNames = null
33
+
34
+ // Function: resolveOwner
35
+ // "user:group" for a uid/gid, resolving names from /etc/passwd and /etc/group
36
+ // (cached once). Falls back to the numeric ids, or "" when both are absent.
37
+ async function resolveOwner(uid, gid) {
38
+ if (uid == null && gid == null) return ""
39
+ if (process.platform !== "win32") {
40
+ passwdNames ??= await parseColonFile("/etc/passwd", 2)
41
+ groupNames ??= await parseColonFile("/etc/group", 2)
42
+ }
43
+ const user = uid == null ? "" : passwdNames?.get(uid) ?? `${uid}`
44
+ const group = gid == null ? "" : groupNames?.get(gid) ?? `${gid}`
45
+ return user && group ? `${user}:${group}` : user || group
46
+ }
47
+
48
+ // Function: parseColonFile
49
+ // Reads a passwd/group-style file into a `{ id -> name }` map; unreadable
50
+ // files yield an empty map (names then fall back to the numeric id).
51
+ async function parseColonFile(path, nameColumn) {
52
+ const map = new Map()
53
+ try {
54
+ const text = await fs.readFile(path, "utf8")
55
+ for (const line of text.split("\n")) {
56
+ const parts = line.split(":")
57
+ const id = Number(parts[2])
58
+ if (parts.length > nameColumn && Number.isFinite(id)) map.set(id, parts[0])
59
+ }
60
+ } catch (error) {
61
+ // No passwd/group file: numeric ids only.
62
+ }
63
+ return map
64
+ }
65
+
66
+ function toEntry(name, dirPath, isDir, stats) {
67
+ const stat = stats || {}
68
+ return {
69
+ name,
70
+ path: pathJoin(dirPath, name),
71
+ isDir,
72
+ isSymlink: Boolean(stat.isSymlink),
73
+ isDevice: Boolean(stat.isDevice),
74
+ size: isDir ? 0 : stat.size ?? 0,
75
+ mtimeMs: stat.mtimeMs ?? 0,
76
+ ctimeMs: stat.ctimeMs ?? 0,
77
+ mode: stat.mode ?? 0,
78
+ uid: stat.uid ?? null,
79
+ gid: stat.gid ?? null,
80
+ ext: isDir ? null : extname(name),
81
+ }
82
+ }
83
+
84
+ // Function: isDeviceNode
85
+ // True when a stat/dirent is a special node (block/char device, FIFO, socket).
86
+ function isDeviceNode(node) {
87
+ return Boolean(
88
+ node?.isBlockDevice?.() || node?.isCharacterDevice?.() || node?.isFIFO?.() || node?.isSocket?.(),
89
+ )
90
+ }
91
+
92
+ // Function: createNodeFs
93
+ // Node/Bun-backed implementation of the filesystem adapter.
94
+ function createNodeFs() {
95
+ const adapter = {
96
+ async readDir(path) {
97
+ const dirents = await fs.readdir(path, { withFileTypes: true })
98
+ const entries = new Array(dirents.length)
99
+ for (let i = 0; i < dirents.length; i++) {
100
+ const d = dirents[i]
101
+ const full = pathJoin(path, d.name)
102
+ const stats = await statSafe(full)
103
+ entries[i] = toEntry(d.name, path, d.isDirectory(), {
104
+ isSymlink: d.isSymbolicLink(),
105
+ isDevice: isDeviceNode(d),
106
+ size: stats?.size ?? 0,
107
+ mtimeMs: stats?.mtimeMs ?? 0,
108
+ ctimeMs: stats?.ctimeMs ?? 0,
109
+ mode: stats?.mode ?? 0,
110
+ uid: stats?.uid ?? null,
111
+ gid: stats?.gid ?? null,
112
+ })
113
+ entries[i].owner = await resolveOwner(stats?.uid, stats?.gid)
114
+ }
115
+ return entries
116
+ },
117
+ async stat(path) {
118
+ return statSafe(path)
119
+ },
120
+ async isDir(path) {
121
+ const stats = await statSafe(path)
122
+ return Boolean(stats?.isDir)
123
+ },
124
+ async readTextHead(path, maxBytes = 65536) {
125
+ const file = nodeFs.openSync(path, "r")
126
+ try {
127
+ const buffer = Buffer.alloc(maxBytes)
128
+ const bytes = nodeFs.readSync(file, buffer, 0, maxBytes, 0)
129
+ return buffer.toString("utf8", 0, bytes)
130
+ } finally {
131
+ nodeFs.closeSync(file)
132
+ }
133
+ },
134
+ async du(path) {
135
+ return duSafe(path)
136
+ },
137
+ async disk(path) {
138
+ try {
139
+ const stats = await fs.statfs(path)
140
+ return { free: Number(stats.bfree * stats.bsize), total: Number(stats.blocks * stats.bsize) }
141
+ } catch (error) {
142
+ return null
143
+ }
144
+ },
145
+ async copy(from, to) {
146
+ await fs.cp(from, to, { recursive: true })
147
+ },
148
+ async move(from, to) {
149
+ try {
150
+ await fs.rename(from, to)
151
+ } catch (error) {
152
+ if (error?.code !== "EXDEV") throw error
153
+ await fs.cp(from, to, { recursive: true })
154
+ await fs.rm(from, { recursive: true })
155
+ }
156
+ },
157
+ async trash(paths) {
158
+ const filesDir = trashDir("files")
159
+ const infoDir = trashDir("info")
160
+ await fs.mkdir(filesDir, { recursive: true })
161
+ await fs.mkdir(infoDir, { recursive: true })
162
+ for (const source of paths) {
163
+ const absolute = nodePath.resolve(source)
164
+ const name = await freeTrashName(filesDir, nodePath.basename(absolute))
165
+ const target = nodePath.join(filesDir, name)
166
+ try {
167
+ await fs.rename(absolute, target)
168
+ } catch (error) {
169
+ if (error?.code !== "EXDEV") throw error
170
+ await fs.cp(absolute, target, { recursive: true })
171
+ await fs.rm(absolute, { recursive: true })
172
+ }
173
+ const info = `[Trash Info]\nPath=${absolute}\nDeletionDate=${new Date().toISOString()}\n`
174
+ await fs.writeFile(nodePath.join(infoDir, `${name}.trashinfo`), info)
175
+ }
176
+ },
177
+ async erase(paths) {
178
+ for (const path of paths) await fs.rm(path, { recursive: true })
179
+ },
180
+ async touch(path) {
181
+ const now = new Date()
182
+ try {
183
+ await fs.utimes(path, now, now)
184
+ } catch (error) {
185
+ const handle = await fs.open(path, "a")
186
+ await handle.close()
187
+ }
188
+ },
189
+ home() {
190
+ return nodeOs.homedir()
191
+ },
192
+ cwd() {
193
+ return process.cwd()
194
+ },
195
+ }
196
+ return adapter
197
+ }
198
+
199
+ // Function: trashDir
200
+ // Freedesktop trash directory under $XDG_DATA_HOME (default
201
+ // ~/.local/share/Trash).
202
+ function trashDir(kind) {
203
+ const dataHome = process.env.XDG_DATA_HOME || nodePath.join(nodeOs.homedir(), ".local", "share")
204
+ return nodePath.join(dataHome, "Trash", kind)
205
+ }
206
+
207
+ // Function: freeTrashName
208
+ // First free `<name>`, `<name>.1`, … in the trash files directory.
209
+ async function freeTrashName(filesDir, base) {
210
+ for (let i = 0; ; i++) {
211
+ const name = i === 0 ? base : `${base}.${i}`
212
+ try {
213
+ await fs.access(nodePath.join(filesDir, name))
214
+ } catch (error) {
215
+ return name
216
+ }
217
+ }
218
+ }
219
+
220
+ async function statSafe(path) {
221
+ try {
222
+ const stats = await fs.stat(path)
223
+ return {
224
+ isDir: stats.isDirectory(),
225
+ isSymlink: stats.isSymbolicLink(),
226
+ isDevice: isDeviceNode(stats),
227
+ size: stats.size,
228
+ mtimeMs: stats.mtimeMs,
229
+ ctimeMs: stats.ctimeMs,
230
+ mode: stats.mode,
231
+ uid: stats.uid,
232
+ gid: stats.gid,
233
+ owner: await resolveOwner(stats.uid, stats.gid),
234
+ }
235
+ } catch (error) {
236
+ return null
237
+ }
238
+ }
239
+
240
+ // Function: duSafe
241
+ // Recursive disk usage; unreadable entries contribute 0 and never throw.
242
+ async function duSafe(path) {
243
+ const stats = await statSafe(path)
244
+ if (!stats) return 0
245
+ if (!stats.isDir) return stats.size
246
+ let total = 0
247
+ let dirents
248
+ try {
249
+ dirents = await fs.readdir(path, { withFileTypes: true })
250
+ } catch (error) {
251
+ return 0
252
+ }
253
+ for (const d of dirents) {
254
+ const full = pathJoin(path, d.name)
255
+ if (d.isDirectory()) total += await duSafe(full)
256
+ else if (!d.isSymbolicLink()) {
257
+ const child = await statSafe(full)
258
+ total += child?.size ?? 0
259
+ }
260
+ }
261
+ return total
262
+ }
263
+
264
+ export { createNodeFs }
265
+
266
+ // EOF
@@ -0,0 +1,39 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core
7
+ // UI-agnostic model and controller for TinyCommand. This layer has no
8
+ // rendering dependencies (no OpenTUI, no select.tui, no DOM) so both the TUI
9
+ // and a future web UI can share it. State is expressed as reactive cells from
10
+ // `select/state/cells`; the filesystem and host adapters are injectable
11
+ // (core/fs, core/os).
12
+
13
+ export { createNodeFs } from "./fs.js"
14
+ export { createNullOs, createNodeOs } from "./os.js"
15
+ export { filterEntries, rankEntries, sortEntries, viewEntries } from "./entries.js"
16
+ export { clampCursor, currentEntry, moveCursor, toggleMark } from "./selection.js"
17
+ export { PANE_BUFFERS, PANE_LABELS, createPane } from "./pane.js"
18
+ export { createDirectory } from "./directory.js"
19
+ export {
20
+ createBookmarks,
21
+ createFileBookmarkStore,
22
+ createMemoryBookmarkStore,
23
+ isValidName,
24
+ normalizeName,
25
+ } from "./bookmarks.js"
26
+ export { DEFAULT_KEYMAP, matchKeymap } from "./keymap.js"
27
+ export { createDispatch, getCommand, getCommands } from "./commands.js"
28
+ export { expandAll, expandArg, decodePathToken, getOperation, getOperations, pathToken, runOperation } from "./operations.js"
29
+ export {
30
+ PANEL_FOCUS,
31
+ argvFor,
32
+ bindArgs,
33
+ panelOperations,
34
+ panelRowList,
35
+ paneSelectionPaths,
36
+ } from "./panel.js"
37
+ export { parseLine, runLine, scriptItems, tokenize } from "./script.js"
38
+ export { createSession, load } from "./session.js"
39
+ export { SEP, basename, extname, join, normalize, parent, prefix, split } from "./path.js"
@@ -0,0 +1,60 @@
1
+ // Project: TinyCommand
2
+ // Author: Sebastien Pierre
3
+ // License: BSD
4
+ // Created: 2026-09-26
5
+
6
+ // Module: tcmd/core/keymap
7
+ // Key chord to command-id mapping. A chord is `{ name, ctrl?, meta?, shift? }`
8
+ // as delivered by OpenTUI key events. Matching is first-match-wins.
9
+
10
+ const DEFAULT_KEYMAP = [
11
+ { key: "up", command: "nav.up" },
12
+ { key: "down", command: "nav.down" },
13
+ { key: "up", ctrl: true, command: "nav.jumpUp" },
14
+ { key: "down", ctrl: true, command: "nav.jumpDown" },
15
+ { key: "pageup", command: "nav.pageUp" },
16
+ { key: "pagedown", command: "nav.pageDown" },
17
+ { key: "home", command: "nav.home" },
18
+ { key: "end", command: "nav.end" },
19
+ { key: "right", command: "pane.enter" },
20
+ { key: "return", command: "pane.enter" },
21
+ { key: "enter", command: "pane.enter" },
22
+ { key: "left", command: "pane.up" },
23
+ { key: "backspace", command: "pane.up" },
24
+ { key: "escape", command: "pane.back" },
25
+ { key: "escape", shift: true, command: "pane.forward" },
26
+ { key: "tab", command: "pane.toggle" },
27
+ { key: "tab", shift: true, command: "pane.toggle" },
28
+ { key: "space", command: "list.mark" },
29
+ { key: "b", command: "bookmark.record" },
30
+ { key: "g", command: "bookmark.jump" },
31
+ { key: "m", command: "panel.toggle" },
32
+ { key: ":", command: "panel.open" },
33
+ { key: "!", command: "palette.run" },
34
+ { key: "/", command: "filter.start" },
35
+ { key: "r", ctrl: true, command: "pane.reload" },
36
+ { key: "v", command: "view.togglePreview" },
37
+ { key: "o", shift: true, command: "file.open" },
38
+ { key: "e", shift: true, command: "file.edit" },
39
+ { key: "p", ctrl: true, command: "palette.toggle" },
40
+ { key: "q", command: "app.quit" },
41
+ ]
42
+
43
+ // Function: matchKeymap
44
+ // Returns the command id for `chord`, or `null` when nothing matches.
45
+ function matchKeymap(keymap, chord) {
46
+ if (!chord?.name) return null
47
+ for (let i = 0; i < keymap.length; i++) {
48
+ const binding = keymap[i]
49
+ if (binding.key !== chord.name) continue
50
+ if (Boolean(binding.ctrl) !== Boolean(chord.ctrl)) continue
51
+ if (Boolean(binding.meta) !== Boolean(chord.meta)) continue
52
+ if (Boolean(binding.shift) !== Boolean(chord.shift)) continue
53
+ return binding.command
54
+ }
55
+ return null
56
+ }
57
+
58
+ export { DEFAULT_KEYMAP, matchKeymap }
59
+
60
+ // EOF