@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.
Files changed (105) hide show
  1. package/README.md +169 -58
  2. package/desktop/dist/index.html +13 -0
  3. package/desktop/dist/main.js +20298 -0
  4. package/desktop/dist/style.css +1515 -0
  5. package/desktop/frontend/index.html +13 -0
  6. package/package.json +17 -5
  7. package/public/fonts/OFL.txt +93 -0
  8. package/public/licenses/LUCIDE.txt +43 -0
  9. package/public/main.js +5066 -1888
  10. package/public/style.css +660 -46
  11. package/scripts/build-desktop.js +31 -0
  12. package/src/js/tcmd/components/App.html +56 -6
  13. package/src/js/tcmd/components/App.js +233 -58
  14. package/src/js/tcmd/components/App.xml +27 -22
  15. package/src/js/tcmd/components/BookmarkList.html +4 -5
  16. package/src/js/tcmd/components/BookmarkList.js +14 -40
  17. package/src/js/tcmd/components/BookmarkList.xml +1 -2
  18. package/src/js/tcmd/components/BookmarkRow.html +2 -1
  19. package/src/js/tcmd/components/BookmarkRow.js +14 -9
  20. package/src/js/tcmd/components/BookmarkRow.xml +1 -1
  21. package/src/js/tcmd/components/CommandArgRow.html +18 -7
  22. package/src/js/tcmd/components/CommandArgRow.js +100 -44
  23. package/src/js/tcmd/components/CommandArgRow.xml +39 -15
  24. package/src/js/tcmd/components/CommandBar.html +1 -1
  25. package/src/js/tcmd/components/CommandBar.js +31 -25
  26. package/src/js/tcmd/components/CommandBar.xml +2 -2
  27. package/src/js/tcmd/components/CommandItem.js +24 -25
  28. package/src/js/tcmd/components/CommandItem.xml +2 -2
  29. package/src/js/tcmd/components/CommandPanel.html +13 -13
  30. package/src/js/tcmd/components/CommandPanel.js +57 -33
  31. package/src/js/tcmd/components/CommandPanel.xml +30 -20
  32. package/src/js/tcmd/components/Confirm.html +2 -2
  33. package/src/js/tcmd/components/EntryDetail.js +21 -7
  34. package/src/js/tcmd/components/FilePicker.html +10 -0
  35. package/src/js/tcmd/components/FilePicker.js +43 -0
  36. package/src/js/tcmd/components/FilePicker.xml +13 -0
  37. package/src/js/tcmd/components/FilePickerRow.html +6 -0
  38. package/src/js/tcmd/components/FilePickerRow.js +27 -0
  39. package/src/js/tcmd/components/FilePickerRow.xml +10 -0
  40. package/src/js/tcmd/components/FilePreview.html +1 -2
  41. package/src/js/tcmd/components/FilePreview.js +40 -4
  42. package/src/js/tcmd/components/FilePreview.xml +17 -10
  43. package/src/js/tcmd/components/FileRow.html +1 -0
  44. package/src/js/tcmd/components/FileRow.js +60 -9
  45. package/src/js/tcmd/components/Frame.js +57 -31
  46. package/src/js/tcmd/components/Help.html +12 -4
  47. package/src/js/tcmd/components/Help.js +198 -47
  48. package/src/js/tcmd/components/Help.xml +13 -4
  49. package/src/js/tcmd/components/HelpRow.html +8 -0
  50. package/src/js/tcmd/components/HelpRow.xml +7 -0
  51. package/src/js/tcmd/components/OverlayList.html +8 -0
  52. package/src/js/tcmd/components/OverlayList.js +69 -0
  53. package/src/js/tcmd/components/OverlayList.xml +13 -0
  54. package/src/js/tcmd/components/OverlayRow.html +5 -0
  55. package/src/js/tcmd/components/OverlayRow.js +47 -0
  56. package/src/js/tcmd/components/OverlayRow.xml +9 -0
  57. package/src/js/tcmd/components/format.js +4 -23
  58. package/src/js/tcmd/components/icons.js +137 -0
  59. package/src/js/tcmd/components/index.js +8 -3
  60. package/src/js/tcmd/components/rows.js +149 -25
  61. package/src/js/tcmd/core/base64.js +64 -0
  62. package/src/js/tcmd/core/bookmarks.js +45 -8
  63. package/src/js/tcmd/core/commands.js +612 -136
  64. package/src/js/tcmd/core/concurrency.js +59 -0
  65. package/src/js/tcmd/core/decompress.js +117 -0
  66. package/src/js/tcmd/core/directory.js +81 -5
  67. package/src/js/tcmd/core/format.js +36 -0
  68. package/src/js/tcmd/core/fs.js +410 -53
  69. package/src/js/tcmd/core/journal.js +93 -0
  70. package/src/js/tcmd/core/keymap.js +39 -3
  71. package/src/js/tcmd/core/operations.js +735 -63
  72. package/src/js/tcmd/core/os.js +29 -3
  73. package/src/js/tcmd/core/pane.js +4 -2
  74. package/src/js/tcmd/core/panel.js +179 -93
  75. package/src/js/tcmd/core/path.js +30 -3
  76. package/src/js/tcmd/core/permissions.js +107 -0
  77. package/src/js/tcmd/core/picker.js +156 -0
  78. package/src/js/tcmd/core/preview.js +102 -0
  79. package/src/js/tcmd/core/session.js +286 -116
  80. package/src/js/tcmd/core/watch.js +153 -0
  81. package/src/js/tcmd/desktop/backend/api.js +165 -0
  82. package/src/js/tcmd/desktop/backend/fs.js +626 -0
  83. package/src/js/tcmd/desktop/backend/main.ts +9 -0
  84. package/src/js/tcmd/desktop/bookmarks.js +23 -0
  85. package/src/js/tcmd/desktop/fs.js +91 -0
  86. package/src/js/tcmd/desktop/main.js +194 -0
  87. package/src/js/tcmd/desktop/os.js +33 -0
  88. package/src/js/tcmd/tui/app.js +111 -74
  89. package/src/js/tcmd/tui/catalogue.js +232 -23
  90. package/src/js/tcmd/tui/index.js +18 -4
  91. package/src/js/tcmd/tui/opentui.js +33 -0
  92. package/src/js/tcmd/tui/syntax.js +91 -0
  93. package/src/js/tcmd/tui/theme.js +27 -2
  94. package/src/js/tcmd/vendor/bzip2.js +276 -0
  95. package/src/js/tcmd/web/app.js +114 -56
  96. package/src/js/tcmd/web/catalogue.js +41 -4
  97. package/src/js/tcmd/web/effects.js +94 -14
  98. package/src/js/tcmd/web/header.html +1 -1
  99. package/src/js/tcmd/web/http.js +13 -0
  100. package/src/js/tcmd/web/index.js +2 -2
  101. package/src/js/tcmd/web/opfs.js +297 -30
  102. package/src/js/tcmd/web/seed.js +1 -1
  103. package/src/js/tcmd/web/server.js +118 -17
  104. package/src/js/tcmd/web/theme.js +3 -0
  105. 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