@ssebastien/tcmd 0.1.2 → 0.1.4
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 +121 -38
- package/bin/tcmd +11 -3
- package/bin/tcmd.js +15 -0
- package/package.json +4 -3
- package/public/index.html +14 -0
- package/public/main.js +17501 -0
- package/public/style.css +901 -0
- package/scripts/serve-web.js +61 -0
- package/src/js/tcmd/components/App.html +28 -12
- package/src/js/tcmd/components/App.js +135 -85
- package/src/js/tcmd/components/App.xml +64 -15
- package/src/js/tcmd/components/BookmarkList.html +3 -2
- package/src/js/tcmd/components/BookmarkList.js +1 -1
- package/src/js/tcmd/components/Breadcrumbs.js +1 -1
- package/src/js/tcmd/components/CommandArgRow.html +17 -0
- package/src/js/tcmd/components/CommandArgRow.js +86 -26
- package/src/js/tcmd/components/CommandArgRow.xml +31 -4
- package/src/js/tcmd/components/CommandBar.html +1 -1
- package/src/js/tcmd/components/CommandBar.js +3 -3
- package/src/js/tcmd/components/CommandItem.html +1 -1
- package/src/js/tcmd/components/CommandItem.js +1 -0
- package/src/js/tcmd/components/CommandPanel.html +29 -0
- package/src/js/tcmd/components/CommandPanel.js +54 -52
- package/src/js/tcmd/components/CommandPanel.xml +38 -24
- package/src/js/tcmd/components/Confirm.html +1 -1
- package/src/js/tcmd/components/EntryDetail.html +7 -7
- package/src/js/tcmd/components/EntryDetail.js +1 -1
- package/src/js/tcmd/components/FileList.html +5 -1
- package/src/js/tcmd/components/FileList.js +2 -1
- package/src/js/tcmd/components/FilePreview.html +14 -3
- package/src/js/tcmd/components/FilePreview.js +73 -10
- package/src/js/tcmd/components/FilePreview.xml +19 -6
- package/src/js/tcmd/components/FileRow.html +1 -1
- package/src/js/tcmd/components/FileRow.js +10 -3
- package/src/js/tcmd/components/FilterBar.html +1 -1
- package/src/js/tcmd/components/FilterBar.js +14 -4
- package/src/js/tcmd/components/FilterBar.xml +1 -1
- package/src/js/tcmd/{tui/frame.js → components/Frame.js} +120 -91
- package/src/js/tcmd/components/Help.html +7 -0
- package/src/js/tcmd/components/Help.js +109 -0
- package/src/js/tcmd/components/Summary.html +1 -1
- package/src/js/tcmd/components/Summary.js +1 -6
- package/src/js/tcmd/components/icons.js +39 -5
- package/src/js/tcmd/components/index.js +3 -4
- package/src/js/tcmd/components/rows.js +37 -33
- package/src/js/tcmd/core/commands.js +422 -180
- package/src/js/tcmd/core/directory.js +145 -95
- package/src/js/tcmd/core/fs.js +29 -2
- package/src/js/tcmd/core/history.js +58 -0
- package/src/js/tcmd/core/index.js +3 -9
- package/src/js/tcmd/core/keymap.js +3 -3
- package/src/js/tcmd/core/operations.js +108 -15
- package/src/js/tcmd/core/pane.js +31 -8
- package/src/js/tcmd/core/panel.js +172 -82
- package/src/js/tcmd/core/script.js +7 -95
- package/src/js/tcmd/core/selection.js +9 -1
- package/src/js/tcmd/core/session.js +280 -152
- package/src/js/tcmd/index.js +0 -1
- package/src/js/tcmd/tui/app.js +26 -5
- package/src/js/tcmd/tui/catalogue.js +6 -19
- package/src/js/tcmd/tui/index.js +1 -16
- package/src/js/tcmd/tui/theme.js +5 -0
- package/src/js/tcmd/web/app.js +158 -32
- package/src/js/tcmd/web/bookmarks.js +36 -0
- package/src/js/tcmd/web/catalogue.js +45 -39
- package/src/js/tcmd/web/effects.js +143 -0
- package/src/js/tcmd/web/header.html +1 -1
- package/src/js/tcmd/web/header.js +15 -11
- package/src/js/tcmd/web/main.js +6 -66
- package/src/js/tcmd/web/seed.js +3 -3
- package/src/js/tcmd/web/server.js +6 -4
- package/src/js/tcmd/web/theme.js +1 -0
- package/src/js/tcmd/components/CommandPalette.html +0 -7
- package/src/js/tcmd/components/CommandPalette.js +0 -58
- package/src/js/tcmd/components/CommandPalette.xml +0 -16
- package/src/js/tcmd/components/PaletteRow.html +0 -3
- package/src/js/tcmd/components/PaletteRow.js +0 -43
- package/src/js/tcmd/components/PaletteRow.xml +0 -4
- package/src/js/tcmd/tui/help.js +0 -32
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
// presentation bind the same source (`Breadcrumbs` uses `RowList.html`).
|
|
14
14
|
|
|
15
15
|
import { ui } from "select/ui"
|
|
16
|
+
import { unwrap } from "select/state/cells"
|
|
16
17
|
import bookmarkListHtml from "../components/BookmarkList.html" with { type: "text" }
|
|
17
18
|
import bookmarkRowHtml from "../components/BookmarkRow.html" with { type: "text" }
|
|
18
19
|
import commandBarHtml from "../components/CommandBar.html" with { type: "text" }
|
|
20
|
+
import commandArgRowHtml from "../components/CommandArgRow.html" with { type: "text" }
|
|
21
|
+
import commandPanelHtml from "../components/CommandPanel.html" with { type: "text" }
|
|
19
22
|
import commandItemHtml from "../components/CommandItem.html" with { type: "text" }
|
|
20
|
-
import commandPaletteHtml from "../components/CommandPalette.html" with { type: "text" }
|
|
21
23
|
import confirmHtml from "../components/Confirm.html" with { type: "text" }
|
|
22
24
|
import crumbHtml from "../components/Crumb.html" with { type: "text" }
|
|
23
25
|
import entryDetailHtml from "../components/EntryDetail.html" with { type: "text" }
|
|
@@ -25,8 +27,8 @@ import fileListHtml from "../components/FileList.html" with { type: "text" }
|
|
|
25
27
|
import filePreviewHtml from "../components/FilePreview.html" with { type: "text" }
|
|
26
28
|
import fileRowHtml from "../components/FileRow.html" with { type: "text" }
|
|
27
29
|
import filterBarHtml from "../components/FilterBar.html" with { type: "text" }
|
|
30
|
+
import helpHtml from "../components/Help.html" with { type: "text" }
|
|
28
31
|
import headerHtml from "./header.html" with { type: "text" }
|
|
29
|
-
import paletteRowHtml from "../components/PaletteRow.html" with { type: "text" }
|
|
30
32
|
import rowListHtml from "../components/RowList.html" with { type: "text" }
|
|
31
33
|
import summaryHtml from "../components/Summary.html" with { type: "text" }
|
|
32
34
|
|
|
@@ -37,9 +39,11 @@ import {
|
|
|
37
39
|
bookmarkRows,
|
|
38
40
|
breadcrumbsBehaviors,
|
|
39
41
|
commandBarBehaviors,
|
|
42
|
+
commandBarMarksItem,
|
|
43
|
+
commandArgRowBehaviors,
|
|
40
44
|
commandItemBehaviors,
|
|
41
45
|
commandItemRows,
|
|
42
|
-
|
|
46
|
+
commandPanelBehaviors,
|
|
43
47
|
confirmBehaviors,
|
|
44
48
|
crumbBehaviors,
|
|
45
49
|
crumbRows,
|
|
@@ -49,55 +53,48 @@ import {
|
|
|
49
53
|
fileRowBehaviors,
|
|
50
54
|
fileRows,
|
|
51
55
|
filterBarBehaviors,
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
helpBehaviors,
|
|
57
|
+
panelArgRows,
|
|
58
|
+
panelOpRows,
|
|
54
59
|
summaryBehaviors,
|
|
55
60
|
} from "../components/index.js"
|
|
56
61
|
import { createBehaviors as headerBehaviors } from "./header.js"
|
|
62
|
+
import { columnWidth } from "./effects.js"
|
|
57
63
|
import { webTheme } from "./theme.js"
|
|
58
64
|
|
|
59
65
|
let registered = false
|
|
60
66
|
|
|
61
|
-
//
|
|
62
|
-
|
|
67
|
+
// Function: paneWidth
|
|
68
|
+
// Reading the injected width cell keeps fitting reactive on pure resizes.
|
|
69
|
+
function paneWidth(self, data) {
|
|
70
|
+
const width = unwrap(data.width) ?? 0
|
|
71
|
+
return columnWidth(self.ref.box) || width
|
|
72
|
+
}
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// 0 when the DOM cannot measure (happy-dom, no layout), disabling compression.
|
|
67
|
-
// ponytail: measured per render; a pure viewport resize waits for the next one.
|
|
68
|
-
function columnWidth(el) {
|
|
69
|
-
const px = el?.clientWidth || el?.ownerDocument?.defaultView?.innerWidth || 0
|
|
70
|
-
if (!px) return 0
|
|
71
|
-
const view = el.ownerDocument?.defaultView
|
|
72
|
-
if (!view) return 0
|
|
73
|
-
if (!charWidth) {
|
|
74
|
-
try {
|
|
75
|
-
const ctx = el.ownerDocument.createElement("canvas").getContext("2d")
|
|
76
|
-
const style = view.getComputedStyle(el)
|
|
77
|
-
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
|
|
78
|
-
charWidth = ctx.measureText("0").width || 0
|
|
79
|
-
} catch {
|
|
80
|
-
return 0
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return charWidth ? Math.floor(px / charWidth) : 0
|
|
74
|
+
function elide(text, width) {
|
|
75
|
+
return width > 0 && text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
84
76
|
}
|
|
85
77
|
|
|
86
78
|
// Function: createComponents
|
|
87
79
|
// Binds every shared component to its `.html` presentation.
|
|
88
80
|
function createComponents() {
|
|
89
81
|
const commandItem = bind(ui, "CommandItem", commandItemHtml, commandItemBehaviors(webTheme))
|
|
82
|
+
// Browsers lack the Nerd Font, so panel entries carry the text tag instead
|
|
83
|
+
// of the glyph.
|
|
84
|
+
const commandArgRow = bind(ui, "CommandArgRow", commandArgRowHtml, commandArgRowBehaviors({
|
|
85
|
+
...webTheme,
|
|
86
|
+
icons: false,
|
|
87
|
+
}))
|
|
90
88
|
const crumb = bind(ui, "Crumb", crumbHtml, crumbBehaviors(webTheme))
|
|
91
89
|
const fileRow = bind(ui, "FileRow", fileRowHtml, fileRowBehaviors(webTheme))
|
|
92
90
|
const filterBar = bind(ui, "FilterBar", filterBarHtml, filterBarBehaviors(webTheme))
|
|
93
|
-
const paletteRow = bind(ui, "PaletteRow", paletteRowHtml, paletteRowBehaviors(webTheme))
|
|
94
91
|
const bookmarkRow = bind(ui, "BookmarkRow", bookmarkRowHtml, bookmarkRowBehaviors(webTheme))
|
|
95
92
|
return {
|
|
96
93
|
BookmarkRow: bookmarkRow,
|
|
97
94
|
CommandItem: commandItem,
|
|
95
|
+
CommandArgRow: commandArgRow,
|
|
98
96
|
Crumb: crumb,
|
|
99
97
|
FileRow: fileRow,
|
|
100
|
-
PaletteRow: paletteRow,
|
|
101
98
|
Breadcrumbs: bind(ui, "Breadcrumbs", rowListHtml, breadcrumbsBehaviors({
|
|
102
99
|
...webTheme,
|
|
103
100
|
listClass: "breadcrumbs",
|
|
@@ -110,25 +107,33 @@ function createComponents() {
|
|
|
110
107
|
CommandBar: bind(ui, "CommandBar", commandBarHtml, commandBarBehaviors({
|
|
111
108
|
...webTheme,
|
|
112
109
|
listClass: "command-bar-keys",
|
|
113
|
-
rows:
|
|
110
|
+
rows: (self, data) => {
|
|
111
|
+
const marks = commandBarMarksItem(data)
|
|
112
|
+
const items = unwrap(data.items) ?? []
|
|
113
|
+
return commandItemRows(commandItem)(self, { ...data, items: marks ? [...items, marks] : items })
|
|
114
|
+
},
|
|
114
115
|
})),
|
|
115
|
-
|
|
116
|
+
CommandPanel: bind(ui, "CommandPanel", commandPanelHtml, commandPanelBehaviors({
|
|
116
117
|
...webTheme,
|
|
117
|
-
|
|
118
|
+
opRows: panelOpRows(commandArgRow),
|
|
119
|
+
argRows: panelArgRows(commandArgRow),
|
|
118
120
|
})),
|
|
119
121
|
Confirm: bind(ui, "Confirm", confirmHtml, confirmBehaviors(webTheme)),
|
|
120
|
-
EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors(webTheme)),
|
|
122
|
+
EntryDetail: bind(ui, "EntryDetail", entryDetailHtml, entryDetailBehaviors({ ...webTheme, width: paneWidth, elide })),
|
|
121
123
|
FileList: bind(ui, "FileList", fileListHtml, fileListBehaviors({
|
|
122
124
|
...webTheme,
|
|
123
125
|
rows: fileRows(fileRow),
|
|
124
126
|
})),
|
|
125
|
-
FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors(
|
|
126
|
-
FilterBar: filterBar,
|
|
127
|
-
Header: bind(ui, "Header", headerHtml, headerBehaviors({
|
|
127
|
+
FilePreview: bind(ui, "FilePreview", filePreviewHtml, filePreviewBehaviors({
|
|
128
128
|
...webTheme,
|
|
129
|
-
|
|
129
|
+
// Open/edit/run are TUI-only: the browser says so instead of
|
|
130
|
+
// advertising keys it cannot honour.
|
|
131
|
+
actions: () => " Open / edit are unavailable in the browser",
|
|
130
132
|
})),
|
|
131
|
-
|
|
133
|
+
FilterBar: filterBar,
|
|
134
|
+
Help: bind(ui, "Help", helpHtml, helpBehaviors({ theme: webTheme })),
|
|
135
|
+
Header: bind(ui, "Header", headerHtml, headerBehaviors(webTheme)),
|
|
136
|
+
Summary: bind(ui, "Summary", summaryHtml, summaryBehaviors({ ...webTheme, width: paneWidth, elide })),
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -143,13 +148,14 @@ function registerWebComponents() {
|
|
|
143
148
|
ui.format("Breadcrumbs", components.Breadcrumbs)
|
|
144
149
|
ui.format("BookmarkList", components.BookmarkList)
|
|
145
150
|
ui.format("CommandBar", components.CommandBar)
|
|
146
|
-
ui.format("
|
|
151
|
+
ui.format("CommandPanel", components.CommandPanel)
|
|
147
152
|
ui.format("Confirm", components.Confirm)
|
|
148
153
|
ui.format("EntryDetail", components.EntryDetail)
|
|
149
154
|
ui.format("FileList", components.FileList)
|
|
150
155
|
ui.format("FilePreview", components.FilePreview)
|
|
151
156
|
ui.format("FilterBar", components.FilterBar)
|
|
152
157
|
ui.format("Header", components.Header)
|
|
158
|
+
ui.format("Help", components.Help)
|
|
153
159
|
ui.format("Summary", components.Summary)
|
|
154
160
|
return components
|
|
155
161
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Project: TinyCommand
|
|
2
|
+
// Author: Sebastien Pierre
|
|
3
|
+
// License: BSD
|
|
4
|
+
// Created: 2026-10-13
|
|
5
|
+
|
|
6
|
+
// Module: tcmd/web/effects
|
|
7
|
+
// DOM effects owned by the mounted App: viewport measurement, modal focus,
|
|
8
|
+
// default-key trapping and keeping the keyboard cursor visible. select.js
|
|
9
|
+
// renders without any of this, so the web binding owns it explicitly.
|
|
10
|
+
|
|
11
|
+
import { INSPECTOR_INLINE_WIDTH } from "../components/Frame.js"
|
|
12
|
+
|
|
13
|
+
const canvases = new WeakMap()
|
|
14
|
+
|
|
15
|
+
// Function: columnWidth
|
|
16
|
+
// Available monospace columns, or zero in a DOM without layout (happy-dom).
|
|
17
|
+
function columnWidth(el) {
|
|
18
|
+
if (!el?.clientWidth) return 0
|
|
19
|
+
const doc = el.ownerDocument
|
|
20
|
+
const view = doc.defaultView
|
|
21
|
+
let canvas = canvases.get(doc)
|
|
22
|
+
if (!canvas) {
|
|
23
|
+
canvas = doc.createElement("canvas")
|
|
24
|
+
canvases.set(doc, canvas)
|
|
25
|
+
}
|
|
26
|
+
const ctx = canvas.getContext("2d")
|
|
27
|
+
if (!ctx) return 0
|
|
28
|
+
const style = view.getComputedStyle(el)
|
|
29
|
+
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
|
|
30
|
+
const width = ctx.measureText("0").width
|
|
31
|
+
const padding = (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0)
|
|
32
|
+
return width ? Math.floor((el.clientWidth - padding) / width) : 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Function: keepVisible
|
|
36
|
+
// Scroll only the row's own list, never the page or an ancestor overlay.
|
|
37
|
+
function keepVisible(list, row) {
|
|
38
|
+
if (!list?.clientHeight || !row) return
|
|
39
|
+
const listRect = list.getBoundingClientRect()
|
|
40
|
+
const rowRect = row.getBoundingClientRect()
|
|
41
|
+
if (rowRect.top < listRect.top) list.scrollTop -= listRect.top - rowRect.top
|
|
42
|
+
else if (rowRect.bottom > listRect.bottom) list.scrollTop += rowRect.bottom - listRect.bottom
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Function: connectEffects
|
|
46
|
+
// Returns a frame scheduler and teardown; the App registers teardown with
|
|
47
|
+
// instance.effect(), so remounting leaves no listeners or observers behind.
|
|
48
|
+
function connectEffects(session, root, { size, help, viewer }) {
|
|
49
|
+
// Select initially clones into an inert template document; the live window
|
|
50
|
+
// is available before mount even though ownerDocument.defaultView is not.
|
|
51
|
+
const view = root.ownerDocument.defaultView ?? globalThis.window
|
|
52
|
+
const doc = view.document
|
|
53
|
+
let frame = null
|
|
54
|
+
let retries = 0
|
|
55
|
+
let disposed = false
|
|
56
|
+
const viewerOpen = () => viewer.value && size.value.width > 0 && size.value.width < INSPECTOR_INLINE_WIDTH
|
|
57
|
+
const modalOpen = () => Boolean(session.confirm.value || help.value || viewerOpen() || session.panelOpen.value || session.bookmarkOpen.value)
|
|
58
|
+
const focusTarget = () => {
|
|
59
|
+
if (session.confirm.value) return root.querySelector(".confirm")
|
|
60
|
+
if (help.value) return root.querySelector(".help")
|
|
61
|
+
if (viewerOpen()) return root.querySelector(".viewer")
|
|
62
|
+
if (session.panelOpen.value) {
|
|
63
|
+
if (session.panelPicking.value) return root.querySelector(".panel-picker-query")
|
|
64
|
+
if (session.panelFocus.value === "ops") return root.querySelector(".panel-query")
|
|
65
|
+
return root.querySelector(session.panelTextual.value ? ".panel-value" : ".command-panel")
|
|
66
|
+
}
|
|
67
|
+
if (session.bookmarkOpen.value) return root.querySelector(".bookmark-query")
|
|
68
|
+
if (session.filtering.value) return root.querySelector(`.pane-${session.filtering.value} .filter-input`)
|
|
69
|
+
return root
|
|
70
|
+
}
|
|
71
|
+
const update = () => {
|
|
72
|
+
frame = null
|
|
73
|
+
if (disposed || !root.isConnected) return
|
|
74
|
+
const width = columnWidth(root)
|
|
75
|
+
const lineHeight = Number.parseFloat(view.getComputedStyle(root).lineHeight)
|
|
76
|
+
const height = lineHeight ? Math.floor(root.clientHeight / lineHeight) : 0
|
|
77
|
+
if (width && height && (size.value.width !== width || size.value.height !== height)) size.set({ width, height })
|
|
78
|
+
// A fullscreen viewer only exists while the viewport is narrow;
|
|
79
|
+
// widening back to the inline inspector drops the stale state so a
|
|
80
|
+
// later narrow resize does not resurrect it. Reads the cell (not just
|
|
81
|
+
// the fresh measurement) so a host that drives `size` directly is
|
|
82
|
+
// honoured too.
|
|
83
|
+
if (viewer.value && size.value.width >= INSPECTOR_INLINE_WIDTH) viewer.set(false)
|
|
84
|
+
// A modal's field mounts on a later frame than the flag that opens it;
|
|
85
|
+
// retry a few frames before giving up so focus lands on the overlay
|
|
86
|
+
// instead of the root.
|
|
87
|
+
const target = focusTarget()
|
|
88
|
+
if (modalOpen() && !target && retries < 8) {
|
|
89
|
+
retries += 1
|
|
90
|
+
schedule()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
retries = 0
|
|
94
|
+
const focusable = target ?? root
|
|
95
|
+
if (focusable !== doc.activeElement) focusable.focus({ preventScroll: true })
|
|
96
|
+
for (const list of root.querySelectorAll(".file-list, .bookmark-rows, .panel-ops, .panel-args")) {
|
|
97
|
+
keepVisible(list, list.querySelector(".is-current, .is-selected, .is-focused"))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const schedule = () => {
|
|
101
|
+
// Root renders queue child renders on the next frame. Wait past those
|
|
102
|
+
// before focusing a newly mounted field or measuring a changed layout.
|
|
103
|
+
if (!disposed && frame === null) frame = view.requestAnimationFrame(() => {
|
|
104
|
+
frame = view.requestAnimationFrame(update)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const trapKeys = (event) => {
|
|
108
|
+
const input = event.target?.tagName === "INPUT" || event.target?.tagName === "TEXTAREA"
|
|
109
|
+
// Editing keeps native text/caret keys except the ones the shared
|
|
110
|
+
// controller explicitly owns (vertical arrows, Enter and Tab).
|
|
111
|
+
if (input && [" ", "Backspace", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) return
|
|
112
|
+
if ([" ", "Backspace", "Enter", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", "Escape"].includes(event.key) ||
|
|
113
|
+
(event.ctrlKey && event.key.toLowerCase() === "r")) event.preventDefault()
|
|
114
|
+
}
|
|
115
|
+
const focusIn = (event) => {
|
|
116
|
+
if (modalOpen() && event.target !== focusTarget()) schedule()
|
|
117
|
+
}
|
|
118
|
+
root.addEventListener("keydown", trapKeys)
|
|
119
|
+
doc.addEventListener("focusin", focusIn)
|
|
120
|
+
root.addEventListener("focusout", schedule)
|
|
121
|
+
view.addEventListener("resize", schedule)
|
|
122
|
+
const observer = view.ResizeObserver ? new view.ResizeObserver(schedule) : null
|
|
123
|
+
observer?.observe(root)
|
|
124
|
+
// Pane proportions also change when the inspector is toggled.
|
|
125
|
+
for (const pane of root.querySelectorAll(".pane")) observer?.observe(pane)
|
|
126
|
+
schedule()
|
|
127
|
+
return {
|
|
128
|
+
schedule,
|
|
129
|
+
dispose() {
|
|
130
|
+
disposed = true
|
|
131
|
+
if (frame !== null) view.cancelAnimationFrame(frame)
|
|
132
|
+
observer?.disconnect()
|
|
133
|
+
root.removeEventListener("keydown", trapKeys)
|
|
134
|
+
root.removeEventListener("focusout", schedule)
|
|
135
|
+
doc.removeEventListener("focusin", focusIn)
|
|
136
|
+
view.removeEventListener("resize", schedule)
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { columnWidth, connectEffects, keepVisible }
|
|
142
|
+
|
|
143
|
+
// EOF
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<header class="app-header" ref="box"><span class="brand-tiny">Tiny</span><span class="brand-cmd">Cmd</span><span out="title"></span></header>
|
|
1
|
+
<header class="app-header" ref="box"><span class="brand-tiny">Tiny</span><span class="brand-cmd">Cmd</span><span out="title"></span><span class="app-header-status"><span class="app-header-mode" out:class="modeClass" out="mode"></span><span out="statusSuffix"></span></span></header>
|
|
@@ -4,32 +4,36 @@
|
|
|
4
4
|
// Created: 2026-09-27
|
|
5
5
|
|
|
6
6
|
// Module: tcmd/web/header
|
|
7
|
-
// Header: the application
|
|
8
|
-
//
|
|
7
|
+
// Header: the slim application identity bar, showing `TinyCmd - HOST` and,
|
|
8
|
+
// right-aligned, the view mode and version (`explorer · 0.1.3`).
|
|
9
9
|
// The brand renders as static spans — `Tiny` with a blue CSS gradient, `Cmd`
|
|
10
10
|
// with a brighter orange one (both kept in sync with `tui/theme.js`) — so the
|
|
11
|
-
// `title` behavior carries only the host
|
|
11
|
+
// `title` behavior carries only the host tail (` - HOST`), `mode`/`modeClass`
|
|
12
|
+
// the gradiented mode word and `statusSuffix` the version that follows it.
|
|
12
13
|
//
|
|
13
|
-
// Environment: `textHeadingFg
|
|
14
|
-
// columns (renderer-specific; 0/omitted disables compression).
|
|
14
|
+
// Environment: `textHeadingFg`.
|
|
15
15
|
|
|
16
16
|
import { unwrap } from "select/state/cells"
|
|
17
|
-
import {
|
|
17
|
+
import { modeWord, versionTrail } from "../components/Frame.js"
|
|
18
18
|
|
|
19
19
|
// Constant: BRAND
|
|
20
|
-
// The brand word the static header spans render
|
|
21
|
-
// the tail doesn't repeat it.
|
|
20
|
+
// The brand word the static header spans render.
|
|
22
21
|
const BRAND = "TinyCmd"
|
|
23
22
|
|
|
24
23
|
// Function: createBehaviors
|
|
25
24
|
// Builds the Header behavior map for the given renderer environment.
|
|
26
25
|
function createBehaviors(env) {
|
|
27
26
|
return {
|
|
28
|
-
title: (
|
|
29
|
-
const
|
|
30
|
-
return
|
|
27
|
+
title: (_self, data) => {
|
|
28
|
+
const host = `${unwrap(data.host) ?? ""}`.trim()
|
|
29
|
+
return host ? ` - ${host}` : ""
|
|
31
30
|
},
|
|
32
31
|
titleFg: () => env.textHeadingFg,
|
|
32
|
+
// The mode word picks the brand gradient it shimmers along: explorer
|
|
33
|
+
// the Tiny blues, commander the Cmd oranges (see `.app-header-mode`).
|
|
34
|
+
mode: (_self, data) => modeWord(unwrap(data.mode)),
|
|
35
|
+
modeClass: (_self, data) => `mode-${modeWord(unwrap(data.mode))}`,
|
|
36
|
+
statusSuffix: () => versionTrail(),
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
package/src/js/tcmd/web/main.js
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
// preferred and HTTP is the fallback, so browsers without OPFS (or insecure
|
|
14
14
|
// origins) still work when the page is served by `bun run web:serve`.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// DOM effects (focus, key trapping, resize, scroll) live in `web/effects.js`
|
|
17
|
+
// and are wired by the App binding in `web/app.js`; bookmarks persist through
|
|
18
|
+
// `web/bookmarks.js`, namespaced by backend and API root.
|
|
19
19
|
//
|
|
20
20
|
// Built by `bun run web:build` (see package.json) into `public/main.js`.
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { createBookmarks } from "../core/bookmarks.js"
|
|
23
23
|
import { createNullOs } from "../core/os.js"
|
|
24
24
|
import { createPane } from "../core/pane.js"
|
|
25
25
|
import { createSession, load } from "../core/session.js"
|
|
@@ -27,64 +27,7 @@ import { apiToken, createHttpFs } from "./http.js"
|
|
|
27
27
|
import { main as mount } from "./index.js"
|
|
28
28
|
import { createOpfsFs } from "./opfs.js"
|
|
29
29
|
import { seedOpfs } from "./seed.js"
|
|
30
|
-
|
|
31
|
-
// Constant: APP_KEYS
|
|
32
|
-
// Keys with browser defaults that would fight the app keymap: focus traversal
|
|
33
|
-
// (Tab), caret/scroll arrows, page scroll (Space) and history Backspace.
|
|
34
|
-
const APP_KEYS = new Set([
|
|
35
|
-
" ",
|
|
36
|
-
"ArrowDown",
|
|
37
|
-
"ArrowLeft",
|
|
38
|
-
"ArrowRight",
|
|
39
|
-
"ArrowUp",
|
|
40
|
-
"Backspace",
|
|
41
|
-
"Enter",
|
|
42
|
-
"Tab",
|
|
43
|
-
])
|
|
44
|
-
|
|
45
|
-
// Function: trapKeys
|
|
46
|
-
// Keeps browser defaults out of the way while the app owns the keyboard. Space
|
|
47
|
-
// and Backspace stay untouched in text inputs so the palette query can be
|
|
48
|
-
// typed; the app routes every other listed key itself.
|
|
49
|
-
function trapKeys(root) {
|
|
50
|
-
root?.addEventListener("keydown", (event) => {
|
|
51
|
-
const inInput = event.target instanceof HTMLInputElement
|
|
52
|
-
if (inInput && (event.key === " " || event.key === "Backspace")) return
|
|
53
|
-
if (APP_KEYS.has(event.key)) event.preventDefault()
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Function: trackFocus
|
|
58
|
-
// The key handler lives on the app root, so focus must stay inside it. The
|
|
59
|
-
// palette input is focused while the palette is open (select.js does not), and
|
|
60
|
-
// the active pane's filter input while that pane is filtering; focus returns to
|
|
61
|
-
// the app root otherwise. Focus that leaves the app — a removed input, a click
|
|
62
|
-
// on the page background — is pulled back.
|
|
63
|
-
function trackFocus(session, root) {
|
|
64
|
-
if (!root) return
|
|
65
|
-
const focusInput = () => {
|
|
66
|
-
if (session.paletteOpen.value) return document.querySelector("#app .palette-input")
|
|
67
|
-
if (session.filtering.value) {
|
|
68
|
-
return document.querySelector(`#app .pane-${session.filtering.value} .filter-input`)
|
|
69
|
-
}
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
const applyFocus = () => {
|
|
73
|
-
// select.js swaps the palette DOM on animation frames: wait for it.
|
|
74
|
-
requestAnimationFrame(() => {
|
|
75
|
-
const input = focusInput()
|
|
76
|
-
if (input) input.focus()
|
|
77
|
-
else if (!root.contains(document.activeElement)) root.focus()
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
effect([session.paletteOpen, session.filtering], applyFocus)
|
|
81
|
-
document.addEventListener("focusout", (event) => {
|
|
82
|
-
if (!session.paletteOpen.value && !session.filtering.value && event.relatedTarget && root.contains(event.relatedTarget)) {
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
applyFocus()
|
|
86
|
-
})
|
|
87
|
-
}
|
|
30
|
+
import { createWebBookmarkStore } from "./bookmarks.js"
|
|
88
31
|
|
|
89
32
|
// Function: createOpfsBackend
|
|
90
33
|
// OPFS adapter, probed and seeded. `readDir("/")` resolves the OPFS root, so
|
|
@@ -128,13 +71,10 @@ async function start() {
|
|
|
128
71
|
left,
|
|
129
72
|
right,
|
|
130
73
|
host: globalThis.location?.hostname ?? "",
|
|
74
|
+
bookmarks: createBookmarks({ store: createWebBookmarkStore({ mode, root: mode === "opfs" ? "/" : fs.root() }) }),
|
|
131
75
|
})
|
|
132
76
|
await load(session)
|
|
133
77
|
const instance = mount(session, "#app")
|
|
134
|
-
const root = document.querySelector("#app .app")
|
|
135
|
-
root?.focus()
|
|
136
|
-
trapKeys(root)
|
|
137
|
-
trackFocus(session, root)
|
|
138
78
|
document.title = `TinyCMD web (${mode})`
|
|
139
79
|
globalThis.tcmd = { fs, mode, session, instance }
|
|
140
80
|
} catch (error) {
|
package/src/js/tcmd/web/seed.js
CHANGED
|
@@ -24,8 +24,8 @@ const DEMO_TREE = {
|
|
|
24
24
|
"- Tab switch pane",
|
|
25
25
|
"- Space mark the current entry",
|
|
26
26
|
"- b / g record / jump to a bookmark",
|
|
27
|
-
"-
|
|
28
|
-
"-
|
|
27
|
+
"- : command panel (operations and app actions)",
|
|
28
|
+
"- ! shell command",
|
|
29
29
|
"- q quit (unmounts the UI; reload to start over)",
|
|
30
30
|
"",
|
|
31
31
|
"Open/edit/run are not available in the browser: the web uses the null",
|
|
@@ -47,7 +47,7 @@ const DEMO_TREE = {
|
|
|
47
47
|
"----",
|
|
48
48
|
"[x] two panes",
|
|
49
49
|
"[x] named bookmarks",
|
|
50
|
-
"[x] command
|
|
50
|
+
"[x] command panel",
|
|
51
51
|
"[ ] quick find",
|
|
52
52
|
].join("\n"),
|
|
53
53
|
"/documents/welcome.txt": [
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
// filesystem API (`/api/*`) over the Node filesystem adapter, so browsers
|
|
9
9
|
// without OPFS (or users who want to browse real files) can run the web UI.
|
|
10
10
|
//
|
|
11
|
-
// Security: binds to loopback only by default
|
|
12
|
-
// token, injected into `index.html` at serve
|
|
13
|
-
// `root` is set, every path is confined to
|
|
11
|
+
// Security: binds to loopback only by default (`HOST` overrides the address)
|
|
12
|
+
// and requires the per-process token, injected into `index.html` at serve
|
|
13
|
+
// time, on every API request. When `root` is set, every path is confined to
|
|
14
|
+
// it. This is a local development
|
|
14
15
|
// tool, not a sandbox: it serves the same files `bun run tcmd` can reach, so
|
|
15
16
|
// do not expose the port.
|
|
16
17
|
|
|
@@ -24,10 +25,11 @@ const TOKEN_PLACEHOLDER = "__TCMD_TOKEN__"
|
|
|
24
25
|
// Function: createWebServer
|
|
25
26
|
// Builds the server. `port` 0 picks a free port (tests); `root` defaults to
|
|
26
27
|
// "/" (the whole filesystem, like the TUI); `token` defaults to a fresh UUID.
|
|
28
|
+
// `hostname` and `port` default to the HOST and PORT environment variables.
|
|
27
29
|
function createWebServer({
|
|
28
30
|
root = process.env.TCMD_WEB_ROOT ?? "/",
|
|
29
31
|
port = Number(process.env.PORT ?? 8787),
|
|
30
|
-
hostname = "127.0.0.1",
|
|
32
|
+
hostname = process.env.HOST || "127.0.0.1",
|
|
31
33
|
publicDir = new URL("../../../../public/", import.meta.url),
|
|
32
34
|
token = crypto.randomUUID(),
|
|
33
35
|
} = {}) {
|
package/src/js/tcmd/web/theme.js
CHANGED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
<section class="palette" when="open">
|
|
2
|
-
<div class="palette-line">
|
|
3
|
-
<span class="palette-sign" out="sign"></span>
|
|
4
|
-
<input ref="query" inout="query" class="palette-input" placeholder="Command..." on:enter="confirm" />
|
|
5
|
-
</div>
|
|
6
|
-
<ul class="palette-list" out="rows"></ul>
|
|
7
|
-
</section>
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// Project: TinyCommand
|
|
2
|
-
// Author: Sebastien Pierre
|
|
3
|
-
// License: BSD
|
|
4
|
-
// Created: 2026-09-26
|
|
5
|
-
|
|
6
|
-
// Module: tcmd/components/CommandPalette
|
|
7
|
-
// CommandPalette: popup listing commands filtered by the typed query. In
|
|
8
|
-
// scripting mode it lists operations and filesets and prefixes the input with
|
|
9
|
-
// `:`. The row logic lives in `PaletteRow.js`; this parent maps its `items` to
|
|
10
|
-
// rows.
|
|
11
|
-
//
|
|
12
|
-
// Environment: `overlayBg`, `borderPrimaryActiveFg`, `textSecondaryFg`
|
|
13
|
-
// (footer), `textHeadingFg` (exposed as the `titleColor` behavior),
|
|
14
|
-
// `rows(data)` returning the keyed rows and `scrollTop(self, data)` (TUI
|
|
15
|
-
// viewport tracking).
|
|
16
|
-
|
|
17
|
-
import { unwrap } from "select/state/cells"
|
|
18
|
-
|
|
19
|
-
// Function: paletteSign
|
|
20
|
-
// The prompt shown before the input; overridable per renderer.
|
|
21
|
-
function paletteSign(mode) {
|
|
22
|
-
return unwrap(mode) === "script" ? ":" : "›"
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Function: paletteTitle
|
|
26
|
-
function paletteTitle(mode) {
|
|
27
|
-
return unwrap(mode) === "script" ? "script" : "palette"
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Function: createBehaviors
|
|
31
|
-
// Builds the CommandPalette behavior map for the given renderer environment.
|
|
32
|
-
function createBehaviors(env) {
|
|
33
|
-
const behaviors = {
|
|
34
|
-
bg: () => env.overlayBg,
|
|
35
|
-
borderFg: () => env.borderPrimaryActiveFg,
|
|
36
|
-
confirm: (self, data) => self.pub("palette:confirm", unwrap(data.query) ?? ""),
|
|
37
|
-
focused: (_self, data) => Boolean(unwrap(data.open)),
|
|
38
|
-
footer: (_self, data) =>
|
|
39
|
-
unwrap(data.mode) === "script"
|
|
40
|
-
? "Tab complete ↵ run Esc close"
|
|
41
|
-
: "↑↓ move Tab complete ↵ run Esc close",
|
|
42
|
-
footerFg: () => env.textSecondaryFg,
|
|
43
|
-
sign: (_self, data) => paletteSign(data.mode),
|
|
44
|
-
title: (_self, data) => paletteTitle(data.mode),
|
|
45
|
-
titleColor: () => env.textHeadingFg,
|
|
46
|
-
}
|
|
47
|
-
if (env?.rows) {
|
|
48
|
-
behaviors.rows = (_self, data) => env.rows(self, data)
|
|
49
|
-
}
|
|
50
|
-
if (env?.scrollTop) {
|
|
51
|
-
behaviors.scrollTop = (self, data) => env.scrollTop(self, data)
|
|
52
|
-
}
|
|
53
|
-
return behaviors
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export { createBehaviors, paletteSign, paletteTitle }
|
|
57
|
-
|
|
58
|
-
// EOF
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<box when="open" width="100%" height="100%" alignItems="center" justifyContent="center">
|
|
2
|
-
<box width="72%" height="60%" border borderStyle="rounded"
|
|
3
|
-
out:title="title" out:titleColor="titleColor" out:borderColor="borderFg"
|
|
4
|
-
out:backgroundColor="bg" flexDirection="column">
|
|
5
|
-
<box flexDirection="row" height="1" paddingLeft="1">
|
|
6
|
-
<text out="sign" out:fg="titleColor"></text>
|
|
7
|
-
<input ref="query" inout="query" flexGrow="1" height="1" out:focused="focused" on:enter="confirm"></input>
|
|
8
|
-
</box>
|
|
9
|
-
<scrollbox ref="list" flexGrow="1">
|
|
10
|
-
<box out="rows" flexDirection="column"></box>
|
|
11
|
-
</scrollbox>
|
|
12
|
-
<box flexDirection="row" height="1" paddingLeft="1">
|
|
13
|
-
<text out="footer" out:fg="footerFg"></text>
|
|
14
|
-
</box>
|
|
15
|
-
</box>
|
|
16
|
-
</box>
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// Project: TinyCommand
|
|
2
|
-
// Author: Sebastien Pierre
|
|
3
|
-
// License: BSD
|
|
4
|
-
// Created: 2026-09-26
|
|
5
|
-
|
|
6
|
-
// Module: tcmd/components/PaletteRow
|
|
7
|
-
// Command-palette row behaviors. Read `{ item: command, index, selected }`, the
|
|
8
|
-
// same bag shape in both engines. Selection is snapshotted by the row builder
|
|
9
|
-
// into a plain `selectedRow` boolean and read through the shared `isSelected`
|
|
10
|
-
// predicate, with the `selected` cell as fallback for hand-built bags. Script
|
|
11
|
-
// rows carry a `detail` (operation signature, fileset count) appended to the
|
|
12
|
-
// label.
|
|
13
|
-
//
|
|
14
|
-
// Environment: `accentFg`, `textFg`, `selectionBg` (TUI row background),
|
|
15
|
-
// `selectedClass` (web).
|
|
16
|
-
|
|
17
|
-
import { isSelected } from "./rows.js"
|
|
18
|
-
|
|
19
|
-
// Function: paletteLabel
|
|
20
|
-
// "Switch [Tab]" for commands; script rows append their detail.
|
|
21
|
-
function paletteLabel(command) {
|
|
22
|
-
if (!command) return ""
|
|
23
|
-
const keys = command.keys ? ` [${command.keys}]` : ""
|
|
24
|
-
const detail = command.detail ? ` ${command.detail}` : ""
|
|
25
|
-
return `${command.title}${keys}${detail}`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// Function: createBehaviors
|
|
29
|
-
// Builds the PaletteRow behavior map for the given renderer environment.
|
|
30
|
-
function createBehaviors(env) {
|
|
31
|
-
return {
|
|
32
|
-
paletteLabel: (_self, data) => paletteLabel(data.item),
|
|
33
|
-
rowBg: (_self, data) => (isSelected(data) ? env.selectionBg : undefined),
|
|
34
|
-
rowClass: (_self, data) => (isSelected(data) ? env.selectedClass : null),
|
|
35
|
-
rowFg: (_self, data) => (isSelected(data) ? env.accentFg : env.textFg),
|
|
36
|
-
// TUI: a marker so the selection is not color-only.
|
|
37
|
-
rowMarker: (_self, data) => (isSelected(data) ? ">" : " "),
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export { createBehaviors, paletteLabel }
|
|
42
|
-
|
|
43
|
-
// EOF
|