@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-31
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/core/watch
|
|
7
|
+
// Change notification for a directory listing. A `Directory` watches the
|
|
8
|
+
// displayed path so listings written by other programs appear without a
|
|
9
|
+
// manual reload. The adapter's native `watch(path, onChange)` is preferred —
|
|
10
|
+
// the Node/Bun adapter maps it to `fs.watch`, so events are immediate. An
|
|
11
|
+
// adapter without it (OPFS, the HTTP API, the desktop RPC bridge) falls back
|
|
12
|
+
// to polling and comparing a listing signature, so the behaviour is the same
|
|
13
|
+
// with a few seconds of latency.
|
|
14
|
+
//
|
|
15
|
+
// Watchers are intentionally owned by the directory rather than the views:
|
|
16
|
+
// every pane has one, and a path change re-arms it (see `core/directory`).
|
|
17
|
+
|
|
18
|
+
// Constant: DEFAULT_DELAY
|
|
19
|
+
// Debounce for native events, which arrive in bursts (a copy emits a change
|
|
20
|
+
// per entry) and would otherwise trigger one read per event.
|
|
21
|
+
const DEFAULT_DELAY = 180
|
|
22
|
+
|
|
23
|
+
// Constant: DEFAULT_INTERVAL
|
|
24
|
+
// Polling cadence for adapters without a native watcher. Long enough not to
|
|
25
|
+
// hammer a remote API, short enough that changes feel automatic.
|
|
26
|
+
const DEFAULT_INTERVAL = 3000
|
|
27
|
+
|
|
28
|
+
// Function: listingSignature
|
|
29
|
+
// A cheap order-independent fingerprint of a listing: names, sizes, mtimes
|
|
30
|
+
// and modes. Polling compares it to decide whether the directory really
|
|
31
|
+
// changed, so an unchanged listing never publishes a new entries array.
|
|
32
|
+
function listingSignature(entries) {
|
|
33
|
+
return (entries ?? [])
|
|
34
|
+
.map(
|
|
35
|
+
(entry) =>
|
|
36
|
+
`${entry?.name ?? ""}\u0000${entry?.size ?? 0}\u0000${entry?.mtimeMs ?? 0}\u0000${entry?.mode ?? 0}`,
|
|
37
|
+
)
|
|
38
|
+
.sort()
|
|
39
|
+
.join("\u0001")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Function: createPathWatcher
|
|
43
|
+
// Watches `path` through the adapter's `watch` when it has one, or by polling
|
|
44
|
+
// `readDir`. Calls `onChange` when the listing may have changed. Returns a
|
|
45
|
+
// `stop()` that closes the native watcher or clears the poll timer; safe to
|
|
46
|
+
// call more than once.
|
|
47
|
+
//
|
|
48
|
+
// `watch` may return its stop synchronously (Node's adapter) or asynchronously
|
|
49
|
+
// (the desktop adapter, whose watch lives behind an RPC round-trip). Only a
|
|
50
|
+
// failed or unavailable native watch starts polling, so a resolved watch never
|
|
51
|
+
// pays for both.
|
|
52
|
+
//
|
|
53
|
+
// Options:
|
|
54
|
+
// - `delay`: native-event debounce in milliseconds (default 180).
|
|
55
|
+
// - `interval`: fallback polling period in milliseconds (default 3000).
|
|
56
|
+
//
|
|
57
|
+
// Example:
|
|
58
|
+
// ```javascript
|
|
59
|
+
// const stop = createPathWatcher(fs, "/home", () => dir.refresh())
|
|
60
|
+
// ```
|
|
61
|
+
function createPathWatcher(fs, path, onChange, options = {}) {
|
|
62
|
+
const delay = options.delay ?? DEFAULT_DELAY
|
|
63
|
+
const interval = options.interval ?? DEFAULT_INTERVAL
|
|
64
|
+
let stopped = false
|
|
65
|
+
let debounceTimer = null
|
|
66
|
+
let pollTimer = null
|
|
67
|
+
let stopNative = null
|
|
68
|
+
|
|
69
|
+
const notify = () => {
|
|
70
|
+
if (stopped) return
|
|
71
|
+
onChange?.()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const startPolling = () => {
|
|
75
|
+
if (stopped || pollTimer || typeof fs?.readDir !== "function") return
|
|
76
|
+
let previous = null
|
|
77
|
+
const poll = async () => {
|
|
78
|
+
if (stopped) return
|
|
79
|
+
let signature
|
|
80
|
+
try {
|
|
81
|
+
signature = listingSignature(await fs.readDir(path))
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A vanishing directory is a change too: the next read reports
|
|
84
|
+
// it to the user instead of leaving a stale listing forever.
|
|
85
|
+
signature = `error:${error?.code ?? error?.message ?? error}`
|
|
86
|
+
}
|
|
87
|
+
if (stopped) return
|
|
88
|
+
if (previous !== null && signature !== previous) notify()
|
|
89
|
+
previous = signature
|
|
90
|
+
pollTimer = setTimeout(() => void poll(), interval)
|
|
91
|
+
pollTimer?.unref?.()
|
|
92
|
+
}
|
|
93
|
+
pollTimer = setTimeout(() => void poll(), interval)
|
|
94
|
+
pollTimer?.unref?.()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const useNativeWatch = (watched) => {
|
|
98
|
+
if (stopped) {
|
|
99
|
+
watched?.()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (typeof watched === "function") {
|
|
103
|
+
stopNative = watched
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
startPolling()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof fs?.watch === "function") {
|
|
110
|
+
let watching = null
|
|
111
|
+
try {
|
|
112
|
+
watching = fs.watch(path, () => {
|
|
113
|
+
if (stopped) return
|
|
114
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
115
|
+
debounceTimer = setTimeout(() => {
|
|
116
|
+
debounceTimer = null
|
|
117
|
+
notify()
|
|
118
|
+
}, delay)
|
|
119
|
+
debounceTimer?.unref?.()
|
|
120
|
+
})
|
|
121
|
+
} catch (error) {
|
|
122
|
+
watching = null
|
|
123
|
+
}
|
|
124
|
+
// An async adapter (the desktop RPC bridge) resolves to its stop; a
|
|
125
|
+
// null/undefined or rejected result means polling takes over.
|
|
126
|
+
if (typeof watching?.then === "function") {
|
|
127
|
+
watching.then(useNativeWatch, () => startPolling())
|
|
128
|
+
} else {
|
|
129
|
+
useNativeWatch(watching)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
startPolling()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
if (stopped) return
|
|
137
|
+
stopped = true
|
|
138
|
+
if (debounceTimer) {
|
|
139
|
+
clearTimeout(debounceTimer)
|
|
140
|
+
debounceTimer = null
|
|
141
|
+
}
|
|
142
|
+
if (pollTimer) {
|
|
143
|
+
clearTimeout(pollTimer)
|
|
144
|
+
pollTimer = null
|
|
145
|
+
}
|
|
146
|
+
stopNative?.()
|
|
147
|
+
stopNative = null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { createPathWatcher, DEFAULT_DELAY, DEFAULT_INTERVAL, listingSignature }
|
|
152
|
+
|
|
153
|
+
// EOF
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/backend/api
|
|
4
|
+
// Named tinyjs RPC methods. The browser receives only the filesystem adapter
|
|
5
|
+
// methods it needs; filesystem and process primitives stay in this backend.
|
|
6
|
+
|
|
7
|
+
import { encodeBase64 } from "../../core/base64.js"
|
|
8
|
+
import { createDesktopFs, normalizePath } from "./fs.js"
|
|
9
|
+
|
|
10
|
+
const OUTPUT_LIMIT = 32 * 1024
|
|
11
|
+
const decoder = new TextDecoder()
|
|
12
|
+
|
|
13
|
+
function requirePath(value, base) {
|
|
14
|
+
return normalizePath(value, base)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readLimited(stream, limit = OUTPUT_LIMIT) {
|
|
18
|
+
if (!stream) return { text: "", truncated: false }
|
|
19
|
+
const reader = stream.getReader()
|
|
20
|
+
const chunks = []
|
|
21
|
+
let size = 0
|
|
22
|
+
let truncated = false
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { value, done } = await reader.read()
|
|
26
|
+
if (done) break
|
|
27
|
+
if (!value?.length) continue
|
|
28
|
+
const remaining = limit - size
|
|
29
|
+
if (remaining > 0) {
|
|
30
|
+
const chunk = value.subarray(0, remaining)
|
|
31
|
+
chunks.push(chunk)
|
|
32
|
+
size += chunk.length
|
|
33
|
+
}
|
|
34
|
+
if (value.length > remaining) truncated = true
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
reader.releaseLock?.()
|
|
38
|
+
}
|
|
39
|
+
const bytes = new Uint8Array(size)
|
|
40
|
+
let offset = 0
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
bytes.set(chunk, offset)
|
|
43
|
+
offset += chunk.length
|
|
44
|
+
}
|
|
45
|
+
return { text: decoder.decode(bytes), truncated }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createDesktopBackend({ runtime } = {}) {
|
|
49
|
+
if (!runtime) throw new TypeError("txiki.js runtime is required")
|
|
50
|
+
let filesystem = null
|
|
51
|
+
let pendingOpenFiles = []
|
|
52
|
+
// Native directory watchers, keyed by window and the frontend's watcher id.
|
|
53
|
+
// The main window owns the whole workspace; a page reload resets its set
|
|
54
|
+
// (`fsWatchReset`) so old watchers never outlive the page that asked.
|
|
55
|
+
const watchers = new Map()
|
|
56
|
+
const watchKey = (meta, id) => `${meta?.window ?? "main"}\u0000${id}`
|
|
57
|
+
const watchId = (id) => {
|
|
58
|
+
if (typeof id !== "string" || !id) throw new TypeError("watch id must be a non-empty string")
|
|
59
|
+
return id
|
|
60
|
+
}
|
|
61
|
+
const fs = () => (filesystem ??= createDesktopFs({ runtime }))
|
|
62
|
+
const fsInfo = () => ({
|
|
63
|
+
home: fs().home(),
|
|
64
|
+
cwd: fs().cwd(),
|
|
65
|
+
root: fs().root(),
|
|
66
|
+
host: runtime.hostName ?? "",
|
|
67
|
+
platform: runtime.env?.OS === "Windows_NT" ? "windows" : "posix",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const api = {
|
|
71
|
+
fsInfo,
|
|
72
|
+
fsReadDir: ({ path }) => fs().readDir(path),
|
|
73
|
+
fsStat: ({ path }) => fs().stat(path),
|
|
74
|
+
fsRealpath: ({ path }) => fs().realpath(path),
|
|
75
|
+
// Directory watching: the frontend keeps a watcher id per pane and
|
|
76
|
+
// receives `fs-change` pushes over one shared channel.
|
|
77
|
+
fsWatch: ({ path, id }, app, meta) => {
|
|
78
|
+
const key = watchKey(meta, watchId(id))
|
|
79
|
+
watchers.get(key)?.close()
|
|
80
|
+
const watcher = runtime.watch(requirePath(path, runtime.cwd), () => app.push("fs-change", { id }))
|
|
81
|
+
watchers.set(key, watcher)
|
|
82
|
+
return true
|
|
83
|
+
},
|
|
84
|
+
fsUnwatch: ({ id }, _app, meta) => {
|
|
85
|
+
const key = watchKey(meta, watchId(id))
|
|
86
|
+
watchers.get(key)?.close()
|
|
87
|
+
watchers.delete(key)
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
fsWatchReset: (_params, _app, meta) => {
|
|
91
|
+
const prefix = watchKey(meta, "")
|
|
92
|
+
for (const [key, watcher] of watchers) {
|
|
93
|
+
if (!key.startsWith(prefix)) continue
|
|
94
|
+
watcher.close()
|
|
95
|
+
watchers.delete(key)
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
},
|
|
99
|
+
fsReadTextHead: ({ path, maxBytes }) => fs().readTextHead(path, maxBytes),
|
|
100
|
+
fsReadBytes: async ({ path, maxBytes }) => ({
|
|
101
|
+
base64: encodeBase64(await fs().readBytes(path, maxBytes)),
|
|
102
|
+
}),
|
|
103
|
+
fsDu: ({ path }) => fs().du(path),
|
|
104
|
+
fsDisk: ({ path }) => fs().disk(path),
|
|
105
|
+
fsCopy: ({ from, to }) => fs().copy(from, to),
|
|
106
|
+
fsMove: ({ from, to }) => fs().move(from, to),
|
|
107
|
+
fsHardlink: ({ from, to }) => fs().hardlink(from, to),
|
|
108
|
+
fsSymlink: ({ target, link }) => fs().symlink(target, link),
|
|
109
|
+
fsChmod: ({ path, mode }) => fs().chmod(path, mode),
|
|
110
|
+
fsChown: ({ path, owner }) => fs().chown(path, owner),
|
|
111
|
+
fsUnlink: ({ path }) => fs().unlink(path),
|
|
112
|
+
fsTrash: ({ paths }) => fs().trash(paths),
|
|
113
|
+
fsTrashList: () => fs().trashList(),
|
|
114
|
+
fsRestore: ({ paths }) => fs().restore(paths),
|
|
115
|
+
fsTakeOpenFiles: () => pendingOpenFiles.splice(0),
|
|
116
|
+
fsErase: ({ paths }) => fs().erase(paths),
|
|
117
|
+
fsTouch: ({ path }) => fs().touch(path),
|
|
118
|
+
fsMkdir: ({ path }) => fs().mkdir(path),
|
|
119
|
+
hostOpen: async ({ path }, app) => app.shell.open(requirePath(path, runtime.cwd)),
|
|
120
|
+
hostEdit: async ({ path }, app) => app.shell.open(requirePath(path, runtime.cwd)),
|
|
121
|
+
hostRun: async ({ command, cwd }, app) => {
|
|
122
|
+
if (typeof command !== "string" || !command.trim()) throw new TypeError("command must be non-empty text")
|
|
123
|
+
if (command.length > 65536 || command.includes("\0")) throw new Error("command is too long or contains NUL")
|
|
124
|
+
const directory = requirePath(cwd || fs().cwd(), runtime.cwd)
|
|
125
|
+
const stats = await fs().stat(directory)
|
|
126
|
+
if (!stats?.isDir) throw new Error(`not a directory: ${directory}`)
|
|
127
|
+
const shell = runtime.env?.SHELL || "/bin/sh"
|
|
128
|
+
const child = runtime.spawn([shell, "-c", command], {
|
|
129
|
+
cwd: directory,
|
|
130
|
+
stdin: "ignore",
|
|
131
|
+
stdout: "pipe",
|
|
132
|
+
stderr: "pipe",
|
|
133
|
+
})
|
|
134
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
135
|
+
readLimited(child.stdout),
|
|
136
|
+
readLimited(child.stderr),
|
|
137
|
+
child.wait(),
|
|
138
|
+
])
|
|
139
|
+
return {
|
|
140
|
+
stdout: stdout.text,
|
|
141
|
+
stderr: stderr.text,
|
|
142
|
+
exitCode: status.exit_status,
|
|
143
|
+
signal: status.term_signal,
|
|
144
|
+
truncated: stdout.truncated || stderr.truncated,
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
api,
|
|
151
|
+
// tinyjs invokes this for folders passed on the command line, associated
|
|
152
|
+
// folders, and files dropped onto the app. The frontend applies them once
|
|
153
|
+
// its session is ready.
|
|
154
|
+
onOpenFiles(paths, app) {
|
|
155
|
+
if (Array.isArray(paths) && paths.length) {
|
|
156
|
+
pendingOpenFiles.push(...paths.filter((path) => typeof path === "string"))
|
|
157
|
+
app.push("open-files", true)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { createDesktopBackend, readLimited }
|
|
164
|
+
|
|
165
|
+
// EOF
|