@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,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
@@ -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). A third, `viewer`, tracks the fullscreen inspector used
13
- // below the inline-inspector breakpoint. None of them exist in the web view.
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, INSPECTOR_INLINE_WIDTH, isExplorer, isNarrow } from "../components/Frame.js"
22
+ import { createFrameBehaviors, currentColumnId, isExplorer, paneColumnWidth, PANE_GROW } from "../components/Frame.js"
22
23
  import { tuiTheme } from "./theme.js"
23
24
 
24
- // Constant: CURRENT_GROW
25
- // How much wider the explorer's middle (current) column is than its
26
- // neighbours — enough to read as the working pane, not enough to starve the
27
- // parent and preview columns.
28
- const CURRENT_GROW = 1.35
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
- const viewer = options.viewer ?? cell(false)
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 a pane's breadcrumb strip: the viewport shared by
49
- // the panes (plus the inline inspector), minus the pane border, the
50
- // header inset, the SRC/DST slot and a safety column. `0` (unknown, web)
51
- // disables the collapse.
52
- const crumbWidth = derived([size, session.showPreview], ([value, showPreview]) => {
53
- const width = Math.floor(value?.width ?? 0)
54
- if (width <= 0) return 0
55
- const columns = showPreview && width >= INSPECTOR_INLINE_WIDTH ? 3 : 2
56
- return Math.max(8, Math.floor(width / columns) - 8)
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
- ...appInit(session)(),
86
- uiSize: size,
87
- helpOpen: help,
88
- viewerOpen: viewer,
89
- leftSegments: leftCrumbs,
90
- parentSegments: parentCrumbs,
91
- rightSegments: rightCrumbs,
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 === "/" && key?.shift)
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 fullscreen viewer: Esc closes, O/E act on the file.
115
- if (viewer.value && isNarrow(data, self)) {
116
- if (name === "escape") {
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
- leftBorderFg: (_self, data) =>
134
- unwrap(data.active) === "left" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
135
- rightBorderFg: (_self, data) =>
136
- unwrap(data.active) === "right" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
137
- parentBorderFg: (_self, data) =>
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: (_self, data) =>
141
- unwrap(data.active) === "left" ? tuiTheme.borderSecondaryActiveFg : tuiTheme.borderSecondaryFg,
142
- rightHeadRuleFg: (_self, data) =>
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" ? CURRENT_GROW : 1,
177
+ isExplorer(data) && currentColumnId(data) === "left" ? PANE_GROW : 1,
152
178
  rightGrow: (_self, data) =>
153
- isExplorer(data) && currentColumnId(data) === "right" ? CURRENT_GROW : 1,
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