@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.
- package/README.md +234 -2
- package/bin/tcmd +7 -0
- package/package.json +37 -5
- package/src/js/tcmd/components/App.html +28 -0
- package/src/js/tcmd/components/App.js +355 -0
- package/src/js/tcmd/components/App.xml +98 -0
- package/src/js/tcmd/components/BookmarkList.html +12 -0
- package/src/js/tcmd/components/BookmarkList.js +73 -0
- package/src/js/tcmd/components/BookmarkList.xml +18 -0
- package/src/js/tcmd/components/BookmarkRow.html +5 -0
- package/src/js/tcmd/components/BookmarkRow.js +40 -0
- package/src/js/tcmd/components/BookmarkRow.xml +10 -0
- package/src/js/tcmd/components/Breadcrumbs.js +27 -0
- package/src/js/tcmd/components/CommandArgRow.js +90 -0
- package/src/js/tcmd/components/CommandArgRow.xml +5 -0
- package/src/js/tcmd/components/CommandBar.html +4 -0
- package/src/js/tcmd/components/CommandBar.js +126 -0
- package/src/js/tcmd/components/CommandBar.xml +4 -0
- package/src/js/tcmd/components/CommandItem.html +3 -0
- package/src/js/tcmd/components/CommandItem.js +78 -0
- package/src/js/tcmd/components/CommandItem.xml +6 -0
- package/src/js/tcmd/components/CommandPalette.html +7 -0
- package/src/js/tcmd/components/CommandPalette.js +58 -0
- package/src/js/tcmd/components/CommandPalette.xml +16 -0
- package/src/js/tcmd/components/CommandPanel.js +98 -0
- package/src/js/tcmd/components/CommandPanel.xml +35 -0
- package/src/js/tcmd/components/Confirm.html +3 -0
- package/src/js/tcmd/components/Confirm.js +36 -0
- package/src/js/tcmd/components/Confirm.xml +7 -0
- package/src/js/tcmd/components/Crumb.html +4 -0
- package/src/js/tcmd/components/Crumb.js +48 -0
- package/src/js/tcmd/components/Crumb.xml +4 -0
- package/src/js/tcmd/components/EntryDetail.html +15 -0
- package/src/js/tcmd/components/EntryDetail.js +194 -0
- package/src/js/tcmd/components/EntryDetail.xml +16 -0
- package/src/js/tcmd/components/FileList.html +1 -0
- package/src/js/tcmd/components/FileList.js +41 -0
- package/src/js/tcmd/components/FileList.xml +7 -0
- package/src/js/tcmd/components/FilePreview.html +5 -0
- package/src/js/tcmd/components/FilePreview.js +110 -0
- package/src/js/tcmd/components/FilePreview.xml +10 -0
- package/src/js/tcmd/components/FileRow.html +5 -0
- package/src/js/tcmd/components/FileRow.js +135 -0
- package/src/js/tcmd/components/FileRow.xml +10 -0
- package/src/js/tcmd/components/FilterBar.html +6 -0
- package/src/js/tcmd/components/FilterBar.js +50 -0
- package/src/js/tcmd/components/FilterBar.xml +6 -0
- package/src/js/tcmd/components/Help.xml +11 -0
- package/src/js/tcmd/components/PaletteRow.html +3 -0
- package/src/js/tcmd/components/PaletteRow.js +43 -0
- package/src/js/tcmd/components/PaletteRow.xml +4 -0
- package/src/js/tcmd/components/RowList.html +1 -0
- package/src/js/tcmd/components/RowList.xml +1 -0
- package/src/js/tcmd/components/Summary.html +3 -0
- package/src/js/tcmd/components/Summary.js +77 -0
- package/src/js/tcmd/components/Summary.xml +3 -0
- package/src/js/tcmd/components/bind.js +20 -0
- package/src/js/tcmd/components/format.js +155 -0
- package/src/js/tcmd/components/icons.js +185 -0
- package/src/js/tcmd/components/index.js +41 -0
- package/src/js/tcmd/components/rows.js +212 -0
- package/src/js/tcmd/core/bookmarks.js +175 -0
- package/src/js/tcmd/core/commands.js +930 -0
- package/src/js/tcmd/core/directory.js +194 -0
- package/src/js/tcmd/core/entries.js +88 -0
- package/src/js/tcmd/core/fs.js +266 -0
- package/src/js/tcmd/core/index.js +39 -0
- package/src/js/tcmd/core/keymap.js +60 -0
- package/src/js/tcmd/core/operations.js +453 -0
- package/src/js/tcmd/core/os.js +128 -0
- package/src/js/tcmd/core/pane.js +74 -0
- package/src/js/tcmd/core/panel.js +383 -0
- package/src/js/tcmd/core/path.js +57 -0
- package/src/js/tcmd/core/script.js +186 -0
- package/src/js/tcmd/core/selection.js +43 -0
- package/src/js/tcmd/core/session.js +405 -0
- package/src/js/tcmd/index.js +53 -0
- package/src/js/tcmd/tui/app.js +140 -0
- package/src/js/tcmd/tui/catalogue.js +300 -0
- package/src/js/tcmd/tui/frame.js +269 -0
- package/src/js/tcmd/tui/help.js +32 -0
- package/src/js/tcmd/tui/index.js +104 -0
- package/src/js/tcmd/tui/theme.js +177 -0
- package/src/js/tcmd/vendor/fzy.js +243 -0
- package/src/js/tcmd/web/app.js +154 -0
- package/src/js/tcmd/web/catalogue.js +159 -0
- package/src/js/tcmd/web/header.html +1 -0
- package/src/js/tcmd/web/header.js +38 -0
- package/src/js/tcmd/web/http.js +79 -0
- package/src/js/tcmd/web/index.js +22 -0
- package/src/js/tcmd/web/main.js +151 -0
- package/src/js/tcmd/web/opfs.js +282 -0
- package/src/js/tcmd/web/seed.js +90 -0
- package/src/js/tcmd/web/server.js +191 -0
- 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
|