@carlesandres/house 0.4.0 → 0.4.2
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/CHANGELOG.md +75 -137
- package/README.md +37 -20
- package/package.json +14 -12
- package/src/Browser.tsx +331 -226
- package/src/CommandPalette.tsx +67 -46
- package/src/Footer.tsx +71 -27
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/PromptRow.tsx +49 -0
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/commands/buildCommands.ts +1 -0
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/io/editor.ts +162 -0
- package/src/keymap/browser.ts +79 -22
- package/src/keymap/keymap.ts +6 -0
- package/src/layout/resolve.ts +9 -9
- package/src/layout/sidebarRow.ts +85 -0
- package/src/serve/server.ts +4 -1
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
package/src/config/load.ts
CHANGED
|
@@ -18,39 +18,96 @@ import { themeDefinitions } from "../theme/registry.ts"
|
|
|
18
18
|
export interface HouseConfig {
|
|
19
19
|
readonly theme: string
|
|
20
20
|
readonly tone: "dark" | "light"
|
|
21
|
+
readonly mdx: boolean
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface CliOverrides {
|
|
24
25
|
readonly theme: string | null
|
|
25
26
|
readonly tone: string | null
|
|
27
|
+
readonly mdx: boolean | null
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const DEFAULT_THEME = "opencode"
|
|
29
31
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
32
|
+
const DEFAULT_MDX = true
|
|
30
33
|
|
|
31
34
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Top-level keys the config file is allowed to set. Kept in sync by hand
|
|
35
38
|
* with `schema` below — when adding a key, add it both places.
|
|
36
|
-
* Used by `fileProvider` to
|
|
37
|
-
*
|
|
39
|
+
* Used by `fileProvider` to warn about unrecognized keys (with a
|
|
40
|
+
* did-you-mean hint when one is close) while still loading the rest.
|
|
38
41
|
*/
|
|
39
|
-
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone"])
|
|
42
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx"])
|
|
40
43
|
|
|
41
44
|
const schema = Config.all({
|
|
42
45
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
43
46
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
47
|
+
// Boolean stored as string literal because providers stringify values
|
|
48
|
+
// (TOML bools, env vars, CLI flags all flow through as text). Mapped to
|
|
49
|
+
// a real boolean in `loadConfig` below.
|
|
50
|
+
mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
|
|
44
51
|
})
|
|
45
52
|
|
|
46
53
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
47
|
-
ConfigProvider.fromUnknown({
|
|
54
|
+
ConfigProvider.fromUnknown({
|
|
55
|
+
theme: DEFAULT_THEME,
|
|
56
|
+
tone: DEFAULT_TONE,
|
|
57
|
+
mdx: String(DEFAULT_MDX),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Levenshtein edit distance, capped at `cap` for early exit.
|
|
62
|
+
* Used only to suggest "did you mean X?" when a config key looks like a
|
|
63
|
+
* typo of a known one. Tiny inputs (≤ ~20 chars), so the naive O(n·m)
|
|
64
|
+
* fill is fine.
|
|
65
|
+
*/
|
|
66
|
+
const editDistance = (a: string, b: string, cap: number): number => {
|
|
67
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1
|
|
68
|
+
const prev: number[] = Array.from({ length: b.length + 1 })
|
|
69
|
+
const curr: number[] = Array.from({ length: b.length + 1 })
|
|
70
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j
|
|
71
|
+
for (let i = 1; i <= a.length; i++) {
|
|
72
|
+
curr[0] = i
|
|
73
|
+
let rowMin = curr[0]!
|
|
74
|
+
for (let j = 1; j <= b.length; j++) {
|
|
75
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
76
|
+
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost)
|
|
77
|
+
if (curr[j]! < rowMin) rowMin = curr[j]!
|
|
78
|
+
}
|
|
79
|
+
if (rowMin > cap) return cap + 1
|
|
80
|
+
for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!
|
|
81
|
+
}
|
|
82
|
+
return prev[b.length]!
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const suggestKey = (unknown: string, known: readonly string[]): string | null => {
|
|
86
|
+
let best: { key: string; dist: number } | null = null
|
|
87
|
+
for (const k of known) {
|
|
88
|
+
const d = editDistance(unknown, k, 2)
|
|
89
|
+
if (d <= 2 && (best === null || d < best.dist)) best = { key: k, dist: d }
|
|
90
|
+
}
|
|
91
|
+
return best?.key ?? null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const formatUnknownKeyWarning = (path: string, key: string, known: readonly string[]): string => {
|
|
95
|
+
const suggestion = suggestKey(key, known)
|
|
96
|
+
const hint = suggestion ? ` — did you mean "${suggestion}"?` : ""
|
|
97
|
+
return `house: ignoring unknown key "${key}" in ${path}${hint}`
|
|
98
|
+
}
|
|
48
99
|
|
|
49
100
|
/**
|
|
50
101
|
* Reads a TOML file at `path`. Missing file → `undefined` for every key
|
|
51
|
-
* (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
|
|
102
|
+
* (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
|
|
103
|
+
* upstream). Unknown top-level keys are warned about via `onWarning` and
|
|
104
|
+
* dropped — this preserves forward-compat with newer config schemas while
|
|
105
|
+
* still flagging typos like `them = "..."`.
|
|
52
106
|
*/
|
|
53
|
-
const fileProvider = (
|
|
107
|
+
const fileProvider = (
|
|
108
|
+
path: string,
|
|
109
|
+
onWarning: (message: string) => void,
|
|
110
|
+
): ConfigProvider.ConfigProvider => {
|
|
54
111
|
let cache: { data: Record<string, unknown> | null } | null = null
|
|
55
112
|
const load = Effect.gen(function* () {
|
|
56
113
|
if (cache !== null) return cache.data
|
|
@@ -69,17 +126,17 @@ const fileProvider = (path: string): ConfigProvider.ConfigProvider => {
|
|
|
69
126
|
cause,
|
|
70
127
|
}),
|
|
71
128
|
})
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
const known = [...KNOWN_FILE_KEYS]
|
|
130
|
+
const filtered: Record<string, unknown> = {}
|
|
131
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
132
|
+
if (KNOWN_FILE_KEYS.has(k)) {
|
|
133
|
+
filtered[k] = v
|
|
134
|
+
} else {
|
|
135
|
+
onWarning(formatUnknownKeyWarning(path, k, known))
|
|
136
|
+
}
|
|
80
137
|
}
|
|
81
|
-
cache = { data:
|
|
82
|
-
return
|
|
138
|
+
cache = { data: filtered }
|
|
139
|
+
return filtered
|
|
83
140
|
})
|
|
84
141
|
return ConfigProvider.make((path) =>
|
|
85
142
|
Effect.gen(function* () {
|
|
@@ -112,8 +169,10 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
112
169
|
const entries: Array<[string, string]> = []
|
|
113
170
|
const theme = env["HOUSE_THEME"]
|
|
114
171
|
const tone = env["HOUSE_TONE"]
|
|
172
|
+
const mdx = env["HOUSE_MDX"]
|
|
115
173
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
116
174
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
175
|
+
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
117
176
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
118
177
|
}
|
|
119
178
|
|
|
@@ -121,6 +180,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
121
180
|
const entries: Array<[string, string]> = []
|
|
122
181
|
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
123
182
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
183
|
+
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
124
184
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
125
185
|
}
|
|
126
186
|
|
|
@@ -130,6 +190,8 @@ export interface LoadOptions {
|
|
|
130
190
|
readonly filePath?: string
|
|
131
191
|
/** Override env (tests). Defaults to `process.env`. */
|
|
132
192
|
readonly env?: Record<string, string>
|
|
193
|
+
/** Sink for non-fatal warnings (unknown keys). Defaults to stderr. */
|
|
194
|
+
readonly onWarning?: (message: string) => void
|
|
133
195
|
}
|
|
134
196
|
|
|
135
197
|
export const defaultConfigPath = (): string =>
|
|
@@ -156,11 +218,14 @@ export const formatConfigError = (err: unknown): string => {
|
|
|
156
218
|
export const loadConfig = (
|
|
157
219
|
options: LoadOptions = {},
|
|
158
220
|
): Effect.Effect<HouseConfig, Config.ConfigError> => {
|
|
159
|
-
const cli = options.cli ?? { theme: null, tone: null }
|
|
221
|
+
const cli = options.cli ?? { theme: null, tone: null, mdx: null }
|
|
222
|
+
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
160
223
|
const provider = cliProvider(cli).pipe(
|
|
161
224
|
ConfigProvider.orElse(envProvider(options.env ?? process.env)),
|
|
162
|
-
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath())),
|
|
225
|
+
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
|
|
163
226
|
ConfigProvider.orElse(defaultsProvider()),
|
|
164
227
|
)
|
|
165
|
-
return schema
|
|
228
|
+
return schema
|
|
229
|
+
.parse(provider)
|
|
230
|
+
.pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
|
|
166
231
|
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface WalkOptions {
|
|
|
19
19
|
readonly all?: boolean
|
|
20
20
|
/** Group order within each directory. Default `dirs-first`. */
|
|
21
21
|
readonly sort?: SortOrder
|
|
22
|
+
/** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
|
|
23
|
+
readonly mdx?: boolean
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
@@ -27,6 +29,7 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
|
27
29
|
}> {}
|
|
28
30
|
|
|
29
31
|
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
|
|
32
|
+
const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
|
|
30
33
|
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
31
34
|
|
|
32
35
|
interface IgnoreLevel {
|
|
@@ -85,7 +88,7 @@ async function* walkDirGen(
|
|
|
85
88
|
dirPath: string,
|
|
86
89
|
rootPath: string,
|
|
87
90
|
parentLevels: readonly IgnoreLevel[],
|
|
88
|
-
opts: { all: boolean; sort: SortOrder },
|
|
91
|
+
opts: { all: boolean; sort: SortOrder; mdx: boolean },
|
|
89
92
|
signal: AbortSignal,
|
|
90
93
|
): AsyncGenerator<FileEntry, void, void> {
|
|
91
94
|
if (signal.aborted) return
|
|
@@ -119,7 +122,8 @@ async function* walkDirGen(
|
|
|
119
122
|
|
|
120
123
|
if (!entry.isFile()) continue
|
|
121
124
|
if (!opts.all && entry.name.startsWith(".")) continue
|
|
122
|
-
|
|
125
|
+
const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
|
|
126
|
+
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
123
127
|
if (!opts.all && isIgnored(entryPath, false, levels)) continue
|
|
124
128
|
|
|
125
129
|
yield {
|
|
@@ -137,7 +141,7 @@ async function* walkDirGen(
|
|
|
137
141
|
* at its next `signal.aborted` check.
|
|
138
142
|
*
|
|
139
143
|
* Rules (see DESIGN.md §6):
|
|
140
|
-
* - Extensions: `.md`, `.markdown`, `.mdx
|
|
144
|
+
* - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
|
|
141
145
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
142
146
|
* - Hidden files/dirs (leading `.`) skipped unless `all: true`.
|
|
143
147
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
@@ -153,6 +157,7 @@ export const walk = (
|
|
|
153
157
|
const opts = {
|
|
154
158
|
all: options.all ?? false,
|
|
155
159
|
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
160
|
+
mdx: options.mdx ?? true,
|
|
156
161
|
}
|
|
157
162
|
const controller = new AbortController()
|
|
158
163
|
const iterable: AsyncIterable<FileEntry> = {
|
package/src/index.tsx
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { stat } from "node:fs/promises"
|
|
12
12
|
import { createCliRenderer, SyntaxStyle } from "@opentui/core"
|
|
13
|
+
import type { BorderSides } from "@opentui/core"
|
|
13
14
|
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
14
15
|
import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
15
16
|
import { Cause, Duration, Effect, Fiber, Stream } from "effect"
|
|
@@ -19,17 +20,21 @@ import { Browser } from "./Browser.tsx"
|
|
|
19
20
|
import { parseArgv, usage } from "./cli/argv.ts"
|
|
20
21
|
import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
|
|
21
22
|
import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
|
|
23
|
+
import { Header } from "./Header.tsx"
|
|
22
24
|
import { readFileText } from "./io/readFile.ts"
|
|
23
25
|
import { openInBrowser } from "./serve/openBrowser.ts"
|
|
24
26
|
import { startServer } from "./serve/server.ts"
|
|
25
27
|
import { colors, setActiveTheme } from "./theme/colors.ts"
|
|
26
28
|
import { themeAtom, type ThemeState } from "./theme/atom.ts"
|
|
27
29
|
import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
|
|
30
|
+
import { formatQuitNotice } from "./update/notice.ts"
|
|
31
|
+
import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
|
|
32
|
+
import { useUpdateNotice } from "./update/useUpdateNotice.ts"
|
|
28
33
|
|
|
29
34
|
export interface AppProps {
|
|
30
35
|
/** Markdown source to render. */
|
|
31
36
|
readonly content: string
|
|
32
|
-
/** Optional title shown in the
|
|
37
|
+
/** Optional title shown in the header's current-file slot. Defaults to a generic label. */
|
|
33
38
|
readonly title?: string
|
|
34
39
|
/** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
|
|
35
40
|
readonly maxWidth?: number | null
|
|
@@ -56,11 +61,13 @@ interface DiscoverShellProps {
|
|
|
56
61
|
readonly target: string
|
|
57
62
|
readonly all: boolean
|
|
58
63
|
readonly sort: SortOrder
|
|
64
|
+
readonly mdx: boolean
|
|
59
65
|
readonly maxWidth: number | null
|
|
60
66
|
readonly sidebarMode: SidebarMode
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShellProps) => {
|
|
69
|
+
const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
|
|
70
|
+
const updateNotice = useUpdateNotice()
|
|
64
71
|
const [files, setFiles] = useState<readonly FileEntry[]>([])
|
|
65
72
|
const [scanning, setScanning] = useState<boolean>(true)
|
|
66
73
|
const [scanError, setScanError] = useState<string | null>(null)
|
|
@@ -69,7 +76,7 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
|
|
|
69
76
|
const countRef = useRef(0)
|
|
70
77
|
|
|
71
78
|
useEffect(() => {
|
|
72
|
-
const program = walk(target, { all, sort }).pipe(
|
|
79
|
+
const program = walk(target, { all, sort, mdx }).pipe(
|
|
73
80
|
Stream.groupedWithin(64, Duration.millis(60)),
|
|
74
81
|
Stream.runForEach((chunk) =>
|
|
75
82
|
Effect.sync(() => {
|
|
@@ -94,17 +101,17 @@ const DiscoverShell = ({ target, all, sort, maxWidth, sidebarMode }: DiscoverShe
|
|
|
94
101
|
return () => {
|
|
95
102
|
Effect.runFork(Fiber.interrupt(fiber))
|
|
96
103
|
}
|
|
97
|
-
}, [target, all, sort])
|
|
104
|
+
}, [target, all, sort, mdx])
|
|
98
105
|
|
|
99
106
|
const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
|
|
100
107
|
|
|
101
108
|
return (
|
|
102
109
|
<Browser
|
|
103
110
|
files={files}
|
|
104
|
-
title={target}
|
|
105
111
|
maxWidth={maxWidth}
|
|
106
112
|
discoveryStatus={discoveryStatus}
|
|
107
113
|
sidebarMode={sidebarMode}
|
|
114
|
+
updateNotice={updateNotice}
|
|
108
115
|
/>
|
|
109
116
|
)
|
|
110
117
|
}
|
|
@@ -145,13 +152,14 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
|
|
|
145
152
|
if (key.name === "l" && key.shift) toggleTone()
|
|
146
153
|
})
|
|
147
154
|
|
|
155
|
+
const paneBorderSides: BorderSides[] = ["top", "bottom"]
|
|
156
|
+
|
|
148
157
|
return (
|
|
149
158
|
<box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
|
|
159
|
+
<Header width={width} currentFile={title} />
|
|
150
160
|
<box
|
|
151
|
-
title={` ${title} `}
|
|
152
|
-
titleAlignment="left"
|
|
153
161
|
style={{
|
|
154
|
-
border:
|
|
162
|
+
border: paneBorderSides,
|
|
155
163
|
borderColor: colors.border,
|
|
156
164
|
padding: 1,
|
|
157
165
|
flexGrow: 1,
|
|
@@ -183,6 +191,8 @@ export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppPr
|
|
|
183
191
|
)
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
let updateExitHookRegistered = false
|
|
195
|
+
|
|
186
196
|
if (import.meta.main) {
|
|
187
197
|
const args = parseArgv(Bun.argv.slice(2))
|
|
188
198
|
|
|
@@ -200,12 +210,20 @@ if (import.meta.main) {
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
const config = await Effect.runPromise(
|
|
203
|
-
loadConfig({
|
|
213
|
+
loadConfig({
|
|
214
|
+
cli: {
|
|
215
|
+
theme: args.theme,
|
|
216
|
+
tone: args.tone,
|
|
217
|
+
// --no-mdx is a one-way override: present means "off". When
|
|
218
|
+
// absent, fall through to env/file/default.
|
|
219
|
+
mdx: args.noMdx ? false : null,
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
204
222
|
).catch((err: unknown) => {
|
|
205
223
|
console.error(`house: ${formatConfigError(err)}`)
|
|
206
224
|
process.exit(2)
|
|
207
225
|
})
|
|
208
|
-
const { theme: themeId, tone } = config
|
|
226
|
+
const { theme: themeId, tone, mdx } = config
|
|
209
227
|
const themeDef = getThemeDefinition(themeId)
|
|
210
228
|
if (themeDef === undefined) {
|
|
211
229
|
// Unreachable: Config.schema validated themeId against themeDefinitions.
|
|
@@ -275,7 +293,17 @@ if (import.meta.main) {
|
|
|
275
293
|
}
|
|
276
294
|
sidebarMode = args.sidebar
|
|
277
295
|
}
|
|
278
|
-
await runTui({
|
|
296
|
+
await runTui({
|
|
297
|
+
target,
|
|
298
|
+
themeId,
|
|
299
|
+
tone,
|
|
300
|
+
maxWidth,
|
|
301
|
+
all: args.all,
|
|
302
|
+
sort,
|
|
303
|
+
mdx,
|
|
304
|
+
sidebarMode,
|
|
305
|
+
updateCheck: !args.noUpdateCheck,
|
|
306
|
+
})
|
|
279
307
|
}
|
|
280
308
|
}
|
|
281
309
|
|
|
@@ -286,7 +314,11 @@ interface TuiBootOptions {
|
|
|
286
314
|
readonly maxWidth: number | null
|
|
287
315
|
readonly all: boolean
|
|
288
316
|
readonly sort: SortOrder
|
|
317
|
+
readonly mdx: boolean
|
|
289
318
|
readonly sidebarMode: SidebarMode
|
|
319
|
+
/** Run the npm-registry probe and surface the "update available" notice.
|
|
320
|
+
* False suppresses both the toast and the quit-time print. */
|
|
321
|
+
readonly updateCheck: boolean
|
|
290
322
|
}
|
|
291
323
|
|
|
292
324
|
async function runTui({
|
|
@@ -296,7 +328,9 @@ async function runTui({
|
|
|
296
328
|
maxWidth,
|
|
297
329
|
all,
|
|
298
330
|
sort,
|
|
331
|
+
mdx,
|
|
299
332
|
sidebarMode,
|
|
333
|
+
updateCheck,
|
|
300
334
|
}: TuiBootOptions): Promise<void> {
|
|
301
335
|
let stats: Awaited<ReturnType<typeof stat>>
|
|
302
336
|
try {
|
|
@@ -306,6 +340,24 @@ async function runTui({
|
|
|
306
340
|
process.exit(1)
|
|
307
341
|
}
|
|
308
342
|
|
|
343
|
+
if (updateCheck) {
|
|
344
|
+
// Fire the npm-registry probe in the background. Result lands in a
|
|
345
|
+
// module singleton; the React tree picks it up via `useUpdateNotice`
|
|
346
|
+
// for the footer toast, and the 'exit' hook below reads it
|
|
347
|
+
// synchronously for the scrollback print. Failures are silent — this
|
|
348
|
+
// whole feature is opportunistic.
|
|
349
|
+
startUpdateProbe(pkg.name, pkg.version)
|
|
350
|
+
// Register once per process. Multiple 'exit' listeners would print
|
|
351
|
+
// the notice multiple times if runTui were ever re-entered.
|
|
352
|
+
if (!updateExitHookRegistered) {
|
|
353
|
+
updateExitHookRegistered = true
|
|
354
|
+
process.on("exit", () => {
|
|
355
|
+
const info = currentUpdateInfo()
|
|
356
|
+
if (info) process.stderr.write(formatQuitNotice(info))
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
309
361
|
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
310
362
|
const initialTheme: ThemeState = { id: themeId, tone }
|
|
311
363
|
|
|
@@ -316,6 +368,7 @@ async function runTui({
|
|
|
316
368
|
target={target}
|
|
317
369
|
all={all}
|
|
318
370
|
sort={sort}
|
|
371
|
+
mdx={mdx}
|
|
319
372
|
maxWidth={maxWidth}
|
|
320
373
|
sidebarMode={sidebarMode}
|
|
321
374
|
/>
|
package/src/io/editor.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve `$VISUAL` / `$EDITOR` into a spawnable `{ cmd, args }`.
|
|
3
|
+
*
|
|
4
|
+
* The string is POSIX shell-split so users with values like
|
|
5
|
+
* `code --wait` or `"/Applications/Sublime Text/subl" --wait` get the
|
|
6
|
+
* expected argv. We deliberately do *not* shell out via `sh -c`: the
|
|
7
|
+
* caller appends the file path as a separate argv element, which avoids
|
|
8
|
+
* command-injection risk for paths containing shell metacharacters.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order: `$VISUAL` → `$EDITOR` → `null`. No silent fallback to
|
|
11
|
+
* `vi`; the caller surfaces a footer notice when neither is set.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ResolvedEditor {
|
|
15
|
+
readonly cmd: string
|
|
16
|
+
readonly args: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Split a command string the way `sh` would for the simple cases users
|
|
21
|
+
* actually put in `$EDITOR`: single quotes (literal), double quotes
|
|
22
|
+
* (with `\"`, `\\` escapes), backslash escapes outside quotes, and
|
|
23
|
+
* whitespace separation. No variable expansion, no globbing, no command
|
|
24
|
+
* substitution — by design. If the input is unbalanced (an unclosed
|
|
25
|
+
* quote), the partial token is emitted as-is so the caller's spawn
|
|
26
|
+
* surfaces a real error instead of us throwing.
|
|
27
|
+
*/
|
|
28
|
+
export const splitEditorString = (input: string): string[] => {
|
|
29
|
+
const tokens: string[] = []
|
|
30
|
+
let buf = ""
|
|
31
|
+
let inSingle = false
|
|
32
|
+
let inDouble = false
|
|
33
|
+
let pendingToken = false
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < input.length; i++) {
|
|
36
|
+
const ch = input[i]!
|
|
37
|
+
if (inSingle) {
|
|
38
|
+
if (ch === "'") inSingle = false
|
|
39
|
+
else buf += ch
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (inDouble) {
|
|
43
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
44
|
+
const next = input[i + 1]!
|
|
45
|
+
// In double quotes, `sh` only treats `\` as an escape before
|
|
46
|
+
// `$`, `` ` ``, `"`, `\`, or newline. Otherwise the backslash
|
|
47
|
+
// is literal. We collapse it for `"` and `\` (the cases users
|
|
48
|
+
// hit with Windows-style paths in double quotes) and keep it
|
|
49
|
+
// literal otherwise.
|
|
50
|
+
if (next === '"' || next === "\\") {
|
|
51
|
+
buf += next
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (ch === '"') {
|
|
57
|
+
inDouble = false
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
buf += ch
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (ch === "'") {
|
|
64
|
+
inSingle = true
|
|
65
|
+
pendingToken = true
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (ch === '"') {
|
|
69
|
+
inDouble = true
|
|
70
|
+
pendingToken = true
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (ch === "\\" && i + 1 < input.length) {
|
|
74
|
+
buf += input[i + 1]!
|
|
75
|
+
i++
|
|
76
|
+
pendingToken = true
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (ch === " " || ch === "\t") {
|
|
80
|
+
if (pendingToken) {
|
|
81
|
+
tokens.push(buf)
|
|
82
|
+
buf = ""
|
|
83
|
+
pendingToken = false
|
|
84
|
+
}
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
buf += ch
|
|
88
|
+
pendingToken = true
|
|
89
|
+
}
|
|
90
|
+
if (pendingToken) tokens.push(buf)
|
|
91
|
+
return tokens
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pick the user's editor from env vars. Returns `null` when neither
|
|
96
|
+
* `$VISUAL` nor `$EDITOR` is set to a non-empty, non-whitespace value.
|
|
97
|
+
*
|
|
98
|
+
* `env` is parameterised so tests don't need to mutate `process.env`.
|
|
99
|
+
*/
|
|
100
|
+
export const resolveEditor = (
|
|
101
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
102
|
+
): ResolvedEditor | null => {
|
|
103
|
+
const raw = pickEnv(env["VISUAL"]) ?? pickEnv(env["EDITOR"])
|
|
104
|
+
if (raw == null) return null
|
|
105
|
+
const parts = splitEditorString(raw)
|
|
106
|
+
if (parts.length === 0) return null
|
|
107
|
+
const [cmd, ...args] = parts
|
|
108
|
+
if (!cmd) return null
|
|
109
|
+
return { cmd, args }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pickEnv = (value: string | undefined): string | null => {
|
|
113
|
+
if (value == null) return null
|
|
114
|
+
const trimmed = value.trim()
|
|
115
|
+
return trimmed.length === 0 ? null : trimmed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Result of a launched editor session. Never throws — every failure is
|
|
119
|
+
* surfaced as a tagged value so the caller can decide UX. */
|
|
120
|
+
export type EditorRunResult =
|
|
121
|
+
| { readonly ok: true; readonly exitCode: number }
|
|
122
|
+
| {
|
|
123
|
+
readonly ok: false
|
|
124
|
+
readonly reason: "spawn-failed" | "non-zero"
|
|
125
|
+
readonly detail?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface OpenInEditorOptions {
|
|
129
|
+
readonly editor: ResolvedEditor
|
|
130
|
+
readonly filePath: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Launch the resolved editor on `filePath`, inheriting stdio so the
|
|
135
|
+
* editor takes over the TTY. Caller is responsible for suspending /
|
|
136
|
+
* resuming the renderer around this call (see Browser.tsx).
|
|
137
|
+
*
|
|
138
|
+
* The file path is passed as a separate argv element — never interpolated
|
|
139
|
+
* into a shell string — so paths with shell metacharacters can't be
|
|
140
|
+
* misinterpreted.
|
|
141
|
+
*
|
|
142
|
+
* Windows is unsupported (see #129). The PATHEXT gap that breaks `.cmd`
|
|
143
|
+
* shims (`code.cmd`, `nvim.cmd`) is tracked specifically in #128.
|
|
144
|
+
*/
|
|
145
|
+
export const openInEditor = async ({
|
|
146
|
+
editor,
|
|
147
|
+
filePath,
|
|
148
|
+
}: OpenInEditorOptions): Promise<EditorRunResult> => {
|
|
149
|
+
const argv = [editor.cmd, ...editor.args, filePath]
|
|
150
|
+
try {
|
|
151
|
+
const proc = Bun.spawn(argv, {
|
|
152
|
+
stdin: "inherit",
|
|
153
|
+
stdout: "inherit",
|
|
154
|
+
stderr: "inherit",
|
|
155
|
+
})
|
|
156
|
+
const exitCode = await proc.exited
|
|
157
|
+
if (exitCode === 0) return { ok: true, exitCode }
|
|
158
|
+
return { ok: false, reason: "non-zero", detail: String(exitCode) }
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { ok: false, reason: "spawn-failed", detail: String(err) }
|
|
161
|
+
}
|
|
162
|
+
}
|