@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,91 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/fs
|
|
4
|
+
// Frontend adapter: forward the shared core's filesystem contract over named
|
|
5
|
+
// tinyjs RPC calls. `createDesktopFs` is async because it caches immutable
|
|
6
|
+
// home/cwd/root values required by the synchronous adapter getters. Raw
|
|
7
|
+
// bytes cross the bridge base64-encoded, like the web API.
|
|
8
|
+
|
|
9
|
+
import { decodeBase64 } from "../core/base64.js"
|
|
10
|
+
|
|
11
|
+
// Function: createDesktopFs
|
|
12
|
+
// Builds the adapter over the tinyjs RPC bridge. `on` subscribes to backend
|
|
13
|
+
// push events (the native watcher); without it the adapter reports no `watch`
|
|
14
|
+
// and `core/watch` falls back to polling.
|
|
15
|
+
function createDesktopFs({ call, on }) {
|
|
16
|
+
if (typeof call !== "function") throw new TypeError("an RPC call function is required")
|
|
17
|
+
const watching = typeof on === "function"
|
|
18
|
+
// One backend channel carries every watcher; ids tell them apart and a
|
|
19
|
+
// page reload restarts the sequence after `fsWatchReset` closes the old
|
|
20
|
+
// page's watchers.
|
|
21
|
+
const watchHandlers = new Map()
|
|
22
|
+
// A per-page prefix keeps ids unique across windows: `app.push` broadcasts
|
|
23
|
+
// every `fs-change` to all of them, and a fixed sequence would make two
|
|
24
|
+
// windows mistake each other's watcher events for their own.
|
|
25
|
+
const watchPrefix = Math.random().toString(36).slice(2, 8)
|
|
26
|
+
let watchSeq = 0
|
|
27
|
+
return (async () => {
|
|
28
|
+
const info = await call("fsInfo")
|
|
29
|
+
if (watching) {
|
|
30
|
+
on("fs-change", (payload) => {
|
|
31
|
+
const id = payload?.id
|
|
32
|
+
if (id != null) watchHandlers.get(id)?.()
|
|
33
|
+
})
|
|
34
|
+
await call("fsWatchReset").catch(() => {})
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
readDir: (path) => call("fsReadDir", { path }),
|
|
38
|
+
stat: (path) => call("fsStat", { path }),
|
|
39
|
+
realpath: (path) => call("fsRealpath", { path }),
|
|
40
|
+
isDir: async (path) => Boolean((await call("fsStat", { path }))?.isDir),
|
|
41
|
+
readTextHead: (path, maxBytes) => call("fsReadTextHead", { path, maxBytes }),
|
|
42
|
+
readBytes: async (path, maxBytes) =>
|
|
43
|
+
decodeBase64((await call("fsReadBytes", { path, maxBytes })).base64),
|
|
44
|
+
du: (path) => call("fsDu", { path }),
|
|
45
|
+
disk: (path) => call("fsDisk", { path }),
|
|
46
|
+
copy: (from, to) => call("fsCopy", { from, to }),
|
|
47
|
+
move: (from, to) => call("fsMove", { from, to }),
|
|
48
|
+
hardlink: (from, to) => call("fsHardlink", { from, to }),
|
|
49
|
+
symlink: (target, link) => call("fsSymlink", { target, link }),
|
|
50
|
+
chmod: (path, mode) => call("fsChmod", { path, mode }),
|
|
51
|
+
chown: (path, owner) => call("fsChown", { path, owner }),
|
|
52
|
+
unlink: (path) => call("fsUnlink", { path }),
|
|
53
|
+
trash: (paths) => call("fsTrash", { paths }),
|
|
54
|
+
trashList: () => call("fsTrashList"),
|
|
55
|
+
restore: (paths) => call("fsRestore", { paths }),
|
|
56
|
+
erase: (paths) => call("fsErase", { paths }),
|
|
57
|
+
touch: (path) => call("fsTouch", { path }),
|
|
58
|
+
mkdir: (path) => call("fsMkdir", { path }),
|
|
59
|
+
// Function: watch
|
|
60
|
+
// Registers a native directory watcher in the backend. Resolves to
|
|
61
|
+
// its stop, or null when the bridge cannot watch the path — which
|
|
62
|
+
// sends `core/watch` to its polling fallback.
|
|
63
|
+
async watch(path, onChange) {
|
|
64
|
+
if (!watching) return null
|
|
65
|
+
const id = `${watchPrefix}-${++watchSeq}`
|
|
66
|
+
watchHandlers.set(id, onChange)
|
|
67
|
+
try {
|
|
68
|
+
await call("fsWatch", { path, id })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
watchHandlers.delete(id)
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
let stopped = false
|
|
74
|
+
return () => {
|
|
75
|
+
if (stopped) return
|
|
76
|
+
stopped = true
|
|
77
|
+
watchHandlers.delete(id)
|
|
78
|
+
void Promise.resolve(call("fsUnwatch", { id })).catch(() => {})
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
home: () => info.home,
|
|
82
|
+
cwd: () => info.cwd,
|
|
83
|
+
root: () => info.root,
|
|
84
|
+
host: () => info.host,
|
|
85
|
+
}
|
|
86
|
+
})()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { createDesktopFs }
|
|
90
|
+
|
|
91
|
+
// EOF
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/main
|
|
4
|
+
// tinyjs frontend bootstrap. This deliberately bypasses the browser entry's
|
|
5
|
+
// OPFS/HTTP backend selection and mounts the existing DOM UI over the native
|
|
6
|
+
// filesystem and host adapters.
|
|
7
|
+
|
|
8
|
+
import { effect } from "select/state/cells"
|
|
9
|
+
import { createBookmarks } from "../core/bookmarks.js"
|
|
10
|
+
import { parent } from "../core/path.js"
|
|
11
|
+
import { createPane } from "../core/pane.js"
|
|
12
|
+
import { createSession, load } from "../core/session.js"
|
|
13
|
+
import { main as mount } from "../web/index.js"
|
|
14
|
+
import { createDesktopBookmarkStore } from "./bookmarks.js"
|
|
15
|
+
import { createDesktopFs } from "./fs.js"
|
|
16
|
+
import { createDesktopOs } from "./os.js"
|
|
17
|
+
|
|
18
|
+
const WORKSPACE_KEY = "tcmd.workspace.v1"
|
|
19
|
+
|
|
20
|
+
function showError(tiny, title, error) {
|
|
21
|
+
const message = error?.message ?? `${error}`
|
|
22
|
+
return tiny.dialog.alert(title, message).catch(() => {})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function start({ doc = globalThis.document, tiny = globalThis.tiny } = {}) {
|
|
26
|
+
const host = doc?.querySelector?.("#app")
|
|
27
|
+
if (!host) throw new Error("TinyCommand desktop page is missing #app")
|
|
28
|
+
if (!tiny?.api?.call) throw new Error("TinyCommand needs the tinyjs API bridge")
|
|
29
|
+
return startApp({ host, doc, tiny })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startApp({ host, doc, tiny }) {
|
|
33
|
+
let stopOpenFiles = null
|
|
34
|
+
let ready = false
|
|
35
|
+
let flushPromise = null
|
|
36
|
+
let flushRequested = false
|
|
37
|
+
let session = null
|
|
38
|
+
let fs = null
|
|
39
|
+
let instance = null
|
|
40
|
+
let stopWorkspaceEffect = null
|
|
41
|
+
let stopDrop = null
|
|
42
|
+
let saveTimer = null
|
|
43
|
+
let disposed = false
|
|
44
|
+
let quitting = false
|
|
45
|
+
function dispose() {
|
|
46
|
+
if (disposed) return
|
|
47
|
+
disposed = true
|
|
48
|
+
ready = false
|
|
49
|
+
clearTimeout(saveTimer)
|
|
50
|
+
stopOpenFiles?.()
|
|
51
|
+
stopDrop?.()
|
|
52
|
+
stopWorkspaceEffect?.()
|
|
53
|
+
instance?.unmount?.()
|
|
54
|
+
}
|
|
55
|
+
const call = (method, params) => tiny.api.call(method, params)
|
|
56
|
+
const flushOpenFiles = () => {
|
|
57
|
+
if (!ready) return Promise.resolve()
|
|
58
|
+
flushRequested = true
|
|
59
|
+
if (flushPromise) return flushPromise
|
|
60
|
+
flushPromise = (async () => {
|
|
61
|
+
while (ready && flushRequested) {
|
|
62
|
+
flushRequested = false
|
|
63
|
+
const paths = await call("fsTakeOpenFiles")
|
|
64
|
+
if (paths?.length) await openPaths(paths)
|
|
65
|
+
}
|
|
66
|
+
})()
|
|
67
|
+
.catch((error) => showError(tiny, "Could not open folder", error))
|
|
68
|
+
.finally(() => {
|
|
69
|
+
flushPromise = null
|
|
70
|
+
if (ready && flushRequested) return flushOpenFiles()
|
|
71
|
+
})
|
|
72
|
+
return flushPromise
|
|
73
|
+
}
|
|
74
|
+
stopOpenFiles = tiny.api.on("open-files", () => {
|
|
75
|
+
if (ready) void flushOpenFiles()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
async function openPaths(paths) {
|
|
79
|
+
for (const rawPath of paths ?? []) {
|
|
80
|
+
if (typeof rawPath !== "string" || !rawPath) continue
|
|
81
|
+
const stats = await fs.stat(rawPath)
|
|
82
|
+
if (!stats) continue
|
|
83
|
+
const folder = stats.isDir ? rawPath : parent(rawPath)
|
|
84
|
+
session.active.set("left")
|
|
85
|
+
await session.panes.left.navigate(folder, { cursorPath: stats.isDir ? null : rawPath })
|
|
86
|
+
return rawPath
|
|
87
|
+
}
|
|
88
|
+
throw new Error("The requested folder or file no longer exists")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function chooseFolder() {
|
|
92
|
+
const path = await tiny.dialog.pickFolder()
|
|
93
|
+
if (path) await openPaths([path])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
fs = await createDesktopFs({ call, on: (event, handler) => tiny.api.on(event, handler) })
|
|
98
|
+
const saved = await tiny.store.get(WORKSPACE_KEY).catch(() => null)
|
|
99
|
+
const validDirectory = async (value) => {
|
|
100
|
+
if (typeof value !== "string") return null
|
|
101
|
+
return await fs.isDir(value).catch(() => false) ? value : null
|
|
102
|
+
}
|
|
103
|
+
const home = fs.home()
|
|
104
|
+
const leftPath = (await validDirectory(saved?.left)) ?? home
|
|
105
|
+
const rightPath = (await validDirectory(saved?.right)) ?? home
|
|
106
|
+
const viewMode = saved?.viewMode === "commander" ? "commander" : "explorer"
|
|
107
|
+
const left = createPane({ fs, path: leftPath })
|
|
108
|
+
const right = createPane({ fs, path: rightPath })
|
|
109
|
+
const bookmarks = createBookmarks({
|
|
110
|
+
store: createDesktopBookmarkStore({ store: tiny.store }),
|
|
111
|
+
})
|
|
112
|
+
session = createSession({
|
|
113
|
+
fs,
|
|
114
|
+
os: createDesktopOs({ call, tiny }),
|
|
115
|
+
left,
|
|
116
|
+
right,
|
|
117
|
+
host: fs.host(),
|
|
118
|
+
bookmarks,
|
|
119
|
+
viewMode,
|
|
120
|
+
})
|
|
121
|
+
await load(session)
|
|
122
|
+
instance = mount(session, "#app", {
|
|
123
|
+
desktopMode: true,
|
|
124
|
+
onQuit: quit,
|
|
125
|
+
onOpenFolder: chooseFolder,
|
|
126
|
+
onError: (error) => showError(tiny, "TinyCommand", error),
|
|
127
|
+
})
|
|
128
|
+
doc.title = "TinyCommand"
|
|
129
|
+
tiny.win.setTitle?.("TinyCommand")
|
|
130
|
+
tiny.win.setMinSize?.(900, 640)
|
|
131
|
+
|
|
132
|
+
let warnedAboutStorage = false
|
|
133
|
+
const saveWorkspace = async (leftLocation, rightLocation, mode) => {
|
|
134
|
+
try {
|
|
135
|
+
const savedOk = await tiny.store.set(WORKSPACE_KEY, {
|
|
136
|
+
left: leftLocation,
|
|
137
|
+
right: rightLocation,
|
|
138
|
+
viewMode: mode,
|
|
139
|
+
})
|
|
140
|
+
if (savedOk === false && !warnedAboutStorage) {
|
|
141
|
+
warnedAboutStorage = true
|
|
142
|
+
session.notice.set({ kind: "warning", text: "Could not save the workspace settings" })
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!warnedAboutStorage) {
|
|
146
|
+
warnedAboutStorage = true
|
|
147
|
+
session.notice.set({ kind: "warning", text: `Could not save workspace settings: ${error.message}` })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function quit() {
|
|
152
|
+
if (quitting || disposed) return
|
|
153
|
+
quitting = true
|
|
154
|
+
clearTimeout(saveTimer)
|
|
155
|
+
try {
|
|
156
|
+
await saveWorkspace(left.path.value, right.path.value, session.viewMode.value)
|
|
157
|
+
await tiny.quit()
|
|
158
|
+
dispose()
|
|
159
|
+
} catch (error) {
|
|
160
|
+
quitting = false
|
|
161
|
+
await showError(tiny, "Could not quit TinyCommand", error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
stopWorkspaceEffect = effect([left.path, right.path, session.viewMode], ([leftLocation, rightLocation, mode]) => {
|
|
165
|
+
if (!ready) return
|
|
166
|
+
clearTimeout(saveTimer)
|
|
167
|
+
saveTimer = setTimeout(() => void saveWorkspace(leftLocation, rightLocation, mode), 350)
|
|
168
|
+
})
|
|
169
|
+
ready = true
|
|
170
|
+
await saveWorkspace(left.path.value, right.path.value, session.viewMode.value)
|
|
171
|
+
stopDrop = tiny.win.onDrop?.((paths) => {
|
|
172
|
+
void openPaths(paths).catch((error) => showError(tiny, "Could not open dropped item", error))
|
|
173
|
+
})
|
|
174
|
+
await flushOpenFiles()
|
|
175
|
+
return {
|
|
176
|
+
fs,
|
|
177
|
+
session,
|
|
178
|
+
instance,
|
|
179
|
+
dispose,
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
dispose()
|
|
183
|
+
host.textContent = `TinyCommand desktop could not start: ${error?.message ?? error}`
|
|
184
|
+
throw error
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (globalThis.document && globalThis.tiny) {
|
|
189
|
+
void start().catch((error) => console.error("TinyCommand desktop startup failed", error))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { start, startApp, WORKSPACE_KEY }
|
|
193
|
+
|
|
194
|
+
// EOF
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// License: BSD
|
|
3
|
+
// Module: tcmd/desktop/os
|
|
4
|
+
// Host adapter for tinyjs. Opening/editing is delegated to the OS's default
|
|
5
|
+
// application. Explicit shell commands run in a non-interactive shell; bounded
|
|
6
|
+
// output is returned to the caller and shown in a native alert. Clipboard
|
|
7
|
+
// writes go through the webview's Clipboard API.
|
|
8
|
+
|
|
9
|
+
import { copyToBrowserClipboard } from "../core/os.js"
|
|
10
|
+
|
|
11
|
+
function createDesktopOs({ call, tiny = globalThis.tiny }) {
|
|
12
|
+
return {
|
|
13
|
+
canEdit: true,
|
|
14
|
+
copy: (text) => copyToBrowserClipboard(text),
|
|
15
|
+
open: (path) => call("hostOpen", { path }),
|
|
16
|
+
edit: (path) => call("hostEdit", { path }),
|
|
17
|
+
async run(command, options = {}) {
|
|
18
|
+
const result = await call("hostRun", { command, cwd: options.cwd })
|
|
19
|
+
const sections = []
|
|
20
|
+
if (result.stdout) sections.push(result.stdout.trimEnd())
|
|
21
|
+
if (result.stderr) sections.push(result.stderr.trimEnd())
|
|
22
|
+
if (result.truncated) sections.push("[Output truncated after 32 KiB per stream]")
|
|
23
|
+
if (result.exitCode !== 0) sections.push(`Process exited with status ${result.exitCode}`)
|
|
24
|
+
if (result.signal) sections.push(`Process stopped by ${result.signal}`)
|
|
25
|
+
if (sections.length) await tiny.dialog.alert("Command output", sections.join("\n\n"))
|
|
26
|
+
return result
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { createDesktopOs }
|
|
32
|
+
|
|
33
|
+
// EOF
|
package/src/js/tcmd/tui/app.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The TUI layers two presentation-only cells on top of the shared controller:
|
|
11
11
|
// `size` (viewport dimensions from the renderer's resize events) and `help`
|
|
12
|
-
// (the `?` overlay).
|
|
13
|
-
//
|
|
12
|
+
// (the `?` overlay). The fullscreen inspector is not one of them: it lives in
|
|
13
|
+
// the session (`viewerOpen`), so a command can open it and both renderers show
|
|
14
|
+
// it (`components/Frame#showViewer`).
|
|
14
15
|
|
|
15
16
|
import { cell, derived, unwrap } from "select/state/cells"
|
|
16
17
|
import { ui } from "select.tui"
|
|
@@ -18,14 +19,50 @@ import appXml from "../components/App.xml" with { type: "text" }
|
|
|
18
19
|
import { elideCrumbs } from "../components/format.js"
|
|
19
20
|
import { appBehaviors, appEvents, appInit } from "../components/index.js"
|
|
20
21
|
import { registerComponents } from "./catalogue.js"
|
|
21
|
-
import { createFrameBehaviors, currentColumnId,
|
|
22
|
+
import { createFrameBehaviors, currentColumnId, isExplorer, paneColumnWidth, PANE_GROW } from "../components/Frame.js"
|
|
22
23
|
import { tuiTheme } from "./theme.js"
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
25
|
+
// Columns available to a pane's breadcrumb strip: the viewport split by the
|
|
26
|
+
// panes' own grow ratios (explorer grows the current column, so its mirrors
|
|
27
|
+
// are narrower than an even split), minus the pane chrome. `0` (unknown, web)
|
|
28
|
+
// disables the collapse.
|
|
29
|
+
function crumbWidthFor(size, session, paneId) {
|
|
30
|
+
return derived([size, session.showPreview, session.viewMode, session.currentId], ([value, showPreview, viewMode, currentId]) => {
|
|
31
|
+
const columns = paneColumnWidth(value?.width, paneId, { viewMode, currentId, showPreview })
|
|
32
|
+
return columns > 0 ? Math.max(8, columns - 8) : 0
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The elided breadcrumb segments for one pane (a presentation-only view of the
|
|
37
|
+
// pane's own segment list; paths stay intact for clicks).
|
|
38
|
+
function paneCrumbs(size, session, paneId, pane) {
|
|
39
|
+
return derived([pane.segments, crumbWidthFor(size, session, paneId)], ([segments, width]) => elideCrumbs(segments, width))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Function: normalizeKey
|
|
43
|
+
// OpenTUI's Kitty keyboard events can report shifted letters in uppercase and
|
|
44
|
+
// punctuation with `shift: true`. The shared keymap uses lowercase letters
|
|
45
|
+
// plus explicit modifiers, while shifted punctuation is already its own key
|
|
46
|
+
// (for example, `:`), so normalize those details before dispatch.
|
|
47
|
+
function normalizeKey(event) {
|
|
48
|
+
const rawName = `${event?.name ?? ""}`
|
|
49
|
+
const sequence = `${event?.sequence ?? ""}`
|
|
50
|
+
const shiftedPunctuation =
|
|
51
|
+
event?.source === "kitty" &&
|
|
52
|
+
Boolean(event?.shift) &&
|
|
53
|
+
rawName.length === 1 &&
|
|
54
|
+
sequence.length === 1 &&
|
|
55
|
+
!/[a-z0-9]/i.test(sequence)
|
|
56
|
+
const nameSource = shiftedPunctuation ? sequence : rawName
|
|
57
|
+
const name = nameSource.toLowerCase()
|
|
58
|
+
const punctuation = name.length === 1 && !/[a-z0-9]/.test(name)
|
|
59
|
+
const shiftedLetter = rawName.length === 1 && rawName !== name && /[a-z]/i.test(rawName)
|
|
60
|
+
return {
|
|
61
|
+
...event,
|
|
62
|
+
name,
|
|
63
|
+
shift: !punctuation && (Boolean(event?.shift) || shiftedLetter),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
29
66
|
|
|
30
67
|
// Function: createAppComponent
|
|
31
68
|
// Builds the root component bound to a `session`.
|
|
@@ -43,29 +80,20 @@ function createAppComponent(session, options = {}) {
|
|
|
43
80
|
registerComponents()
|
|
44
81
|
const size = options.size ?? cell({ width: 0, height: 0 })
|
|
45
82
|
const help = options.help ?? cell(false)
|
|
46
|
-
|
|
83
|
+
// The fullscreen inspector overlay lives in the session (core), so a
|
|
84
|
+
// command — Enter on a file that cannot be edited — can open it; the
|
|
85
|
+
// renderer only shows it (`components/Frame#showViewer`).
|
|
86
|
+
const viewer = session.viewerOpen
|
|
47
87
|
|
|
48
|
-
// Columns available to
|
|
49
|
-
// the
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
})
|
|
58
|
-
// The elided breadcrumb segments for each pane (presentation-only view of
|
|
59
|
-
// the pane's own segment list; paths stay intact for clicks).
|
|
60
|
-
const leftCrumbs = derived([session.panes.left.segments, crumbWidth], ([segments, width]) =>
|
|
61
|
-
elideCrumbs(segments, width),
|
|
62
|
-
)
|
|
63
|
-
const rightCrumbs = derived([session.panes.right.segments, crumbWidth], ([segments, width]) =>
|
|
64
|
-
elideCrumbs(segments, width),
|
|
65
|
-
)
|
|
66
|
-
const parentCrumbs = derived([session.panes.parent.segments, crumbWidth], ([segments, width]) =>
|
|
67
|
-
elideCrumbs(segments, width),
|
|
68
|
-
)
|
|
88
|
+
// Columns available to each pane's breadcrumb strip: the pane's own share
|
|
89
|
+
// of the viewport (explorer's current column is wider, so its mirrors are
|
|
90
|
+
// narrower), minus the pane chrome. `0` (unknown, web) disables the
|
|
91
|
+
// collapse. An even `width / columns` split used to overflow the narrower
|
|
92
|
+
// mirror columns, which the renderer then mangled.
|
|
93
|
+
const leftCrumbs = paneCrumbs(size, session, "left", session.panes.left)
|
|
94
|
+
const rightCrumbs = paneCrumbs(size, session, "right", session.panes.right)
|
|
95
|
+
const parentCrumbs = paneCrumbs(size, session, "parent", session.panes.parent)
|
|
96
|
+
const previewCrumbs = paneCrumbs(size, session, "preview", session.panes.preview)
|
|
69
97
|
|
|
70
98
|
const quit = options.quit ?? ((self) => {
|
|
71
99
|
// Destroying the renderer from within a keypress deadlocks the native
|
|
@@ -78,28 +106,44 @@ function createAppComponent(session, options = {}) {
|
|
|
78
106
|
|
|
79
107
|
const baseKeys = appBehaviors({ quit }).keys
|
|
80
108
|
const frame = createFrameBehaviors({ theme: tuiTheme })
|
|
109
|
+
const appSubs = appEvents()
|
|
110
|
+
|
|
111
|
+
// The active pane's border and head rule take the view mode's own accent:
|
|
112
|
+
// the Tiny blue in explorer, the gold accent in commander. An inactive
|
|
113
|
+
// pane keeps the muted border. `isExplorer` defaults an absent mode to
|
|
114
|
+
// commander, matching the rest of the workspace.
|
|
115
|
+
const paneActiveFg = (data) =>
|
|
116
|
+
isExplorer(data) ? tuiTheme.borderActiveExplorerFg : tuiTheme.borderSecondaryActiveFg
|
|
117
|
+
const paneBorderFg = (paneId) => (_self, data) =>
|
|
118
|
+
unwrap(data.active) === paneId ? paneActiveFg(data) : tuiTheme.borderSecondaryFg
|
|
81
119
|
|
|
82
120
|
return ui
|
|
83
121
|
.component("App")
|
|
84
|
-
.init(() =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
122
|
+
.init((self) => {
|
|
123
|
+
// The panel's Quit command reaches the renderer through this hook.
|
|
124
|
+
session.quit = () => quit(self)
|
|
125
|
+
return {
|
|
126
|
+
...appInit(session)(),
|
|
127
|
+
uiSize: size,
|
|
128
|
+
helpOpen: help,
|
|
129
|
+
viewerOpen: viewer,
|
|
130
|
+
leftSegments: leftCrumbs,
|
|
131
|
+
parentSegments: parentCrumbs,
|
|
132
|
+
rightSegments: rightCrumbs,
|
|
133
|
+
previewSegments: previewCrumbs,
|
|
134
|
+
}
|
|
135
|
+
})
|
|
93
136
|
.does({
|
|
94
137
|
...frame,
|
|
95
138
|
keys: (self, data, event) => {
|
|
96
|
-
const key = event
|
|
139
|
+
const key = normalizeKey(event)
|
|
97
140
|
const name = key?.name
|
|
98
|
-
const isQuestion = name === "?" || (name === "/" &&
|
|
141
|
+
const isQuestion = name === "?" || (name === "/" && event?.shift)
|
|
99
142
|
const busy =
|
|
100
143
|
unwrap(data.confirm) ||
|
|
101
144
|
unwrap(data.filtering) ||
|
|
102
145
|
unwrap(data.bookmarkOpen) ||
|
|
146
|
+
unwrap(data.overlay) ||
|
|
103
147
|
unwrap(data.panelOpen)
|
|
104
148
|
|
|
105
149
|
// Help overlay owns the keyboard while open.
|
|
@@ -111,48 +155,41 @@ function createAppComponent(session, options = {}) {
|
|
|
111
155
|
help.set(true)
|
|
112
156
|
return
|
|
113
157
|
}
|
|
114
|
-
// The
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
viewer.set(false)
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
if (name === "o" && key.shift) return data.dispatch("file.open", { paneId: unwrap(data.active) })
|
|
121
|
-
if (name === "e" && key.shift) return data.dispatch("file.edit", { paneId: unwrap(data.active) })
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
// Below the inline breakpoint `v` toggles the viewer overlay
|
|
125
|
-
// instead of the side panel (`⇧V` still maximizes).
|
|
126
|
-
if (!busy && name === "v" && !key.shift && isNarrow(data, self)) {
|
|
127
|
-
viewer.set(!viewer.value)
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
return baseKeys(self, data, event)
|
|
158
|
+
// The viewer overlay and the narrow-`v` shortcut are shared
|
|
159
|
+
// modal routing, handled by `components/App#createBehaviors`.
|
|
160
|
+
return baseKeys(self, data, key)
|
|
131
161
|
},
|
|
132
|
-
// TUI-only presentation of the shared App state
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
parentBorderFg: (
|
|
138
|
-
unwrap(data.active) === "parent" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
|
|
162
|
+
// TUI-only presentation of the shared App state: each pane's
|
|
163
|
+
// border and header rule track its own focus, tinted by the view
|
|
164
|
+
// mode (explorer blue, commander gold).
|
|
165
|
+
leftBorderFg: paneBorderFg("left"),
|
|
166
|
+
rightBorderFg: paneBorderFg("right"),
|
|
167
|
+
parentBorderFg: paneBorderFg("parent"),
|
|
139
168
|
// The header rule carries the same focus tint as the pane border.
|
|
140
|
-
leftHeadRuleFg: (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
unwrap(data.active) === "right" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
|
|
144
|
-
parentHeadRuleFg: (_self, data) =>
|
|
145
|
-
unwrap(data.active) === "parent" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
|
|
169
|
+
leftHeadRuleFg: paneBorderFg("left"),
|
|
170
|
+
rightHeadRuleFg: paneBorderFg("right"),
|
|
171
|
+
parentHeadRuleFg: paneBorderFg("parent"),
|
|
146
172
|
// In explorer the current column takes the extra width; in
|
|
147
173
|
// commander both panes share the row evenly. The columns carry
|
|
148
174
|
// `flexBasis="0"` so the whole row is distributed by these
|
|
149
175
|
// growers instead of by the content each column would claim.
|
|
150
176
|
leftGrow: (_self, data) =>
|
|
151
|
-
isExplorer(data) && currentColumnId(data) === "left" ?
|
|
177
|
+
isExplorer(data) && currentColumnId(data) === "left" ? PANE_GROW : 1,
|
|
152
178
|
rightGrow: (_self, data) =>
|
|
153
|
-
isExplorer(data) && currentColumnId(data) === "right" ?
|
|
179
|
+
isExplorer(data) && currentColumnId(data) === "right" ? PANE_GROW : 1,
|
|
180
|
+
})
|
|
181
|
+
.sub({
|
|
182
|
+
...appSubs,
|
|
183
|
+
// The bar's `[Help ?]` entry opens the overlay, the same path as the
|
|
184
|
+
// `?` key (the renderer owns the help cell).
|
|
185
|
+
"commandbar:run": (self, data, event) => {
|
|
186
|
+
if (event.data === "app.help") {
|
|
187
|
+
help.set(!help.value)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
appSubs["commandbar:run"](self, data, event)
|
|
191
|
+
},
|
|
154
192
|
})
|
|
155
|
-
.sub(appEvents({ quit }))
|
|
156
193
|
.using(appXml)
|
|
157
194
|
}
|
|
158
195
|
|