refactor: extract TUI to standalone plugin repository

This commit is contained in:
saberzero1 2026-02-25 19:07:41 +01:00
parent 22f63e360d
commit e4666dfa72
No known key found for this signature in database
16 changed files with 6 additions and 5190 deletions

View File

@ -17,7 +17,6 @@
"check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write",
"test": "tsx --test",
"build:tui": "node quartz/cli/build-tui.mjs",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1",
"install-plugins": "npx tsx ./quartz/plugins/loader/install-plugins.ts",
"prebuild": "npm run install-plugins"
@ -42,8 +41,6 @@
"@floating-ui/dom": "^1.7.4",
"@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.22",
"@opentui/core": "^0.1.80",
"@opentui/react": "^0.1.80",
"ansi-truncate": "^1.4.0",
"async-mutex": "^0.5.0",
"chokidar": "^5.0.0",
@ -68,7 +65,6 @@
"preact-render-to-string": "^6.6.5",
"pretty-bytes": "^7.1.0",
"pretty-time": "^1.1.0",
"react": "^19.2.4",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.3.1",
@ -106,7 +102,6 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.10",
"@types/pretty-time": "^1.1.5",
"@types/react": "^19.2.14",
"@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1",
"@types/yargs": "^17.0.35",

View File

@ -33,17 +33,17 @@ import {
import { version } from "./cli/constants.js"
async function launchTui() {
const { pathToFileURL } = await import("url")
const { join } = await import("path")
const { existsSync } = await import("fs")
const { spawn } = await import("child_process")
const tuiPath = join(process.cwd(), "quartz", "cli", "tui", "dist", "App.mjs")
const tuiPath = join(process.cwd(), ".quartz", "plugins", "tui", "dist", "App.mjs")
if (!existsSync(tuiPath)) {
console.log("TUI not built yet. Building...")
const buildScript = pathToFileURL(join(process.cwd(), "quartz", "cli", "build-tui.mjs")).href
await import(buildScript)
console.log("TUI built successfully.")
console.error(
"TUI plugin not installed. Install with:\n" +
" npx quartz plugin add github:quartz-community/tui\n"
)
process.exit(1)
}
// OpenTUI requires Bun runtime (uses bun:ffi for Zig renderer)

View File

@ -1,21 +0,0 @@
#!/usr/bin/env node
import esbuild from "esbuild"
import path from "path"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tuiDir = path.join(__dirname, "tui")
await esbuild.build({
entryPoints: [path.join(tuiDir, "App.tsx")],
outdir: path.join(tuiDir, "dist"),
bundle: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "@opentui/react",
packages: "external",
sourcemap: true,
target: "esnext",
outExtension: { ".js": ".mjs" },
})

View File

@ -1,114 +0,0 @@
import { useCallback, useState } from "react"
import { createCliRenderer } from "@opentui/core"
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
import { Notification, type NotificationMessage } from "./components/Notification.js"
import { SetupWizard } from "./components/SetupWizard.js"
import { StatusBar } from "./components/StatusBar.js"
import { LayoutPanel } from "./panels/LayoutPanel.js"
import { PluginsPanel } from "./panels/PluginsPanel.js"
import { SettingsPanel } from "./panels/SettingsPanel.js"
import { version } from "../constants.js"
import { configExists } from "../plugin-data.js"
const TABS = ["Plugins", "Layout", "Settings"] as const
type Tab = (typeof TABS)[number]
export function App() {
const renderer = useRenderer()
const { height: rows } = useTerminalDimensions()
const [hasConfig, setHasConfig] = useState(() => configExists())
const [activeTab, setActiveTab] = useState<Tab>("Plugins")
const [notification, setNotification] = useState<NotificationMessage | null>(null)
const [panelFocused, setPanelFocused] = useState(false)
const notify = useCallback((message: string, type: "success" | "error" | "info" = "info") => {
setNotification({ message, type })
setTimeout(() => setNotification(null), 3000)
}, [])
useKeyboard((event) => {
if (panelFocused || !hasConfig) return
if (event.name !== "q") return
renderer.destroy()
process.exit(0)
})
useKeyboard((event) => {
if (!hasConfig || panelFocused) return
if (event.name !== "tab") return
const currentIndex = TABS.indexOf(activeTab)
const next = event.shift
? (currentIndex - 1 + TABS.length) % TABS.length
: (currentIndex + 1) % TABS.length
setActiveTab(TABS[next])
})
if (!hasConfig) {
return (
<box flexDirection="column" height={rows}>
<box justifyContent="center" paddingY={0}>
<text>
<span fg="green">
<strong>{` Quartz v${version} Plugin Manager `}</strong>
</span>
</text>
</box>
<box flexDirection="column" flexGrow={1} justifyContent="center">
<SetupWizard
onComplete={() => {
setHasConfig(true)
notify("Configuration created", "success")
}}
/>
</box>
{notification && <Notification message={notification} />}
</box>
)
}
return (
<box flexDirection="column" height={rows}>
<box justifyContent="center" paddingY={0}>
<text>
<span fg="green">
<strong>{` Quartz v${version} Plugin Manager `}</strong>
</span>
</text>
</box>
<box flexDirection="row" paddingX={1} gap={2}>
{TABS.map((tab) => (
<text key={tab}>
{tab === activeTab ? (
<span fg="cyan">
<strong>[ {tab} ]</strong>
</span>
) : (
<span fg="#888888">{` ${tab} `}</span>
)}
</text>
))}
</box>
<box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{activeTab === "Plugins" && (
<PluginsPanel notify={notify} onFocusChange={setPanelFocused} />
)}
{activeTab === "Layout" && <LayoutPanel notify={notify} onFocusChange={setPanelFocused} />}
{activeTab === "Settings" && (
<SettingsPanel notify={notify} onFocusChange={setPanelFocused} />
)}
</box>
{notification && <Notification message={notification} />}
<StatusBar activeTab={activeTab} />
</box>
)
}
const renderer = await createCliRenderer({ exitOnCtrlC: true })
createRoot(renderer).render(<App />)

View File

@ -1,572 +0,0 @@
import fs from "fs"
import path from "path"
import { execFile } from "child_process"
import { promisify } from "util"
import {
readPluginsJson,
writePluginsJson,
readLockfile,
writeLockfile,
extractPluginName,
readManifestFromPackageJson,
parseGitSource,
PLUGINS_DIR,
} from "../plugin-data.js"
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
interface LockfileEntry {
source: string
resolved: string
commit: string
installedAt: string
}
interface Lockfile {
version: string
plugins: Record<string, LockfileEntry>
}
interface PluginsJsonEntry {
source: string
enabled?: boolean
options?: Record<string, unknown>
order?: number
layout?: {
position: string
priority?: number
display?: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
}
}
interface PluginsJson {
plugins?: PluginsJsonEntry[]
}
interface PluginManifestComponent {
defaultPosition?: string
defaultPriority?: number
}
interface PluginManifest {
defaultEnabled?: boolean
defaultOptions?: Record<string, unknown>
defaultOrder?: number
components?: Record<string, PluginManifestComponent>
}
const INTERNAL_EXPORTS = new Set(["manifest", "default"])
const execFileAsync = promisify(execFile)
function report(
onProgress: ProgressCallback | undefined,
message: string,
type: OperationResultType,
) {
onProgress?.(message, type)
}
type OperationResultType = "info" | "success" | "error" | "warning"
function shortCommit(commit: string) {
return commit.slice(0, 7)
}
async function runGit(args: string[], cwd?: string, timeout = 60000) {
const { stdout } = await execFileAsync("git", args, { cwd, timeout, encoding: "utf-8" })
return stdout.trim()
}
async function runNpm(args: string[], cwd: string, timeout = 300000) {
await execFileAsync("npm", args, { cwd, timeout, encoding: "utf-8" })
}
async function getGitCommitAsync(pluginDir: string) {
try {
const commit = await runGit(["rev-parse", "HEAD"], pluginDir)
return commit || "unknown"
} catch {
return "unknown"
}
}
async function buildPlugin(
pluginDir: string,
name: string,
onProgress?: ProgressCallback,
): Promise<boolean> {
try {
report(onProgress, `${name}: installing dependencies...`, "info")
await runNpm(["install"], pluginDir)
report(onProgress, `${name}: building...`, "info")
await runNpm(["run", "build"], pluginDir)
return true
} catch (error) {
report(onProgress, `${name}: build failed`, "error")
return false
}
}
function needsBuild(pluginDir: string) {
const distDir = path.join(pluginDir, "dist")
return !fs.existsSync(distDir)
}
function parseExportsFromDts(content: string) {
const exports: string[] = []
const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g)
for (const match of exportMatches) {
const fromModule = match[2]
if (fromModule?.startsWith("@")) continue
const names = match[1]
.split(",")
.map((n) => n.trim())
.filter(Boolean)
for (const name of names) {
const cleanName = name.split(" as ").pop()?.trim() || name.trim()
if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) {
const finalName = cleanName.replace(/^type\s+/, "")
if (name.includes("type ")) {
exports.push(`type ${finalName}`)
} else {
exports.push(finalName)
}
}
}
}
return exports
}
async function regeneratePluginIndex() {
if (!fs.existsSync(PLUGINS_DIR)) return
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true })
const plugins = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
const exports: string[] = []
for (const pluginName of plugins) {
const pluginDir = path.join(PLUGINS_DIR, pluginName)
const distIndex = path.join(pluginDir, "dist", "index.d.ts")
if (!fs.existsSync(distIndex)) continue
const dtsContent = await fs.promises.readFile(distIndex, "utf-8")
const exportedNames = parseExportsFromDts(dtsContent)
if (exportedNames.length > 0) {
const namedExports = exportedNames.filter((e) => !e.startsWith("type "))
const typeExports = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5))
if (namedExports.length > 0) {
exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`)
}
if (typeExports.length > 0) {
exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`)
}
}
}
const indexContent = exports.join("\n") + "\n"
const indexPath = path.join(PLUGINS_DIR, "index.ts")
await fs.promises.writeFile(indexPath, indexContent)
}
export async function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No quartz.lock.json found. Run 'npx quartz plugin add <repo>' first."
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
report(onProgress, "→ Installing plugins from lockfile...", "info")
let installed = 0
let failed = 0
const errors: string[] = []
const pluginsToBuild: Array<{ name: string; pluginDir: string }> = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
try {
const currentCommit = await getGitCommitAsync(pluginDir)
if (currentCommit === entry.commit && !needsBuild(pluginDir)) {
report(onProgress, `${name}@${shortCommit(entry.commit)} already installed`, "info")
installed++
continue
}
if (currentCommit !== entry.commit) {
report(onProgress, `${name}: updating to ${shortCommit(entry.commit)}...`, "info")
await runGit(["fetch", "--depth", "1", "origin"], pluginDir)
await runGit(["reset", "--hard", entry.commit], pluginDir)
}
pluginsToBuild.push({ name, pluginDir })
installed++
} catch (error) {
const message = `${name}: failed to update`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
} else {
try {
report(onProgress, `${name}: cloning...`, "info")
await runGit(["clone", "--depth", "1", entry.resolved, pluginDir])
if (entry.commit !== "unknown") {
await runGit(["fetch", "--depth", "1", "origin", entry.commit], pluginDir)
await runGit(["checkout", entry.commit], pluginDir)
}
report(onProgress, `${name}@${shortCommit(entry.commit)}`, "success")
pluginsToBuild.push({ name, pluginDir })
installed++
} catch (error) {
const message = `${name}: failed to clone`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
}
if (pluginsToBuild.length > 0) {
report(onProgress, "→ Building plugins...", "info")
for (const { name, pluginDir } of pluginsToBuild) {
if (!(await buildPlugin(pluginDir, name, onProgress))) {
failed++
installed--
} else {
report(onProgress, `${name} built`, "success")
}
}
}
await regeneratePluginIndex()
if (failed === 0) {
report(onProgress, `✓ Installed ${installed} plugin(s)`, "success")
} else {
report(onProgress, `⚠ Installed ${installed} plugin(s), ${failed} failed`, "warning")
}
return { success: failed === 0, installed, failed, errors }
}
export async function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
let lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
lockfile = { version: "1.0.0", plugins: {} }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
const addedPlugins: Array<{ name: string; pluginDir: string; source: string }> = []
const errors: string[] = []
let failed = 0
for (const source of sources) {
try {
const { name, url, ref } = parseGitSource(source)
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
report(onProgress, `${name} already exists. Use 'update' to refresh.`, "warning")
continue
}
report(onProgress, `→ Adding ${name} from ${url}...`, "info")
if (ref) {
await runGit(["clone", "--depth", "1", "--branch", ref, url, pluginDir])
} else {
await runGit(["clone", "--depth", "1", url, pluginDir])
}
const commit = await getGitCommitAsync(pluginDir)
lockfile.plugins[name] = {
source,
resolved: url,
commit,
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
report(onProgress, `✓ Added ${name}@${shortCommit(commit)}`, "success")
} catch (error) {
const message = `✗ Failed to add ${source}: ${error instanceof Error ? error.message : error}`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (addedPlugins.length > 0) {
report(onProgress, "→ Building plugins...", "info")
for (const { name, pluginDir } of addedPlugins) {
if (await buildPlugin(pluginDir, name, onProgress)) {
report(onProgress, `${name} built`, "success")
}
}
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson() as PluginsJson | null
if (pluginsJson?.plugins) {
for (const { pluginDir, source } of addedPlugins) {
const manifest = readManifestFromPackageJson(pluginDir) as PluginManifest | null
const newEntry: PluginsJsonEntry = {
source,
enabled: manifest?.defaultEnabled ?? true,
options: manifest?.defaultOptions ?? {},
order: manifest?.defaultOrder ?? 50,
}
if (manifest?.components) {
const firstComponentKey = Object.keys(manifest.components)[0]
const comp = manifest.components[firstComponentKey]
if (comp?.defaultPosition) {
newEntry.layout = {
position: comp.defaultPosition,
priority: comp.defaultPriority ?? 50,
display: "all",
}
}
}
pluginsJson.plugins.push(newEntry)
}
writePluginsJson(pluginsJson as Record<string, unknown>)
}
report(onProgress, "Updated quartz.lock.json", "info")
return { success: failed === 0, installed: addedPlugins.length, failed, errors }
}
export async function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No plugins installed"
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
let removed = false
let removedCount = 0
const errors: string[] = []
for (const name of names) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (!lockfile.plugins[name] && !fs.existsSync(pluginDir)) {
report(onProgress, `${name} is not installed`, "warning")
continue
}
report(onProgress, `→ Removing ${name}...`, "info")
try {
if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true })
}
delete lockfile.plugins[name]
report(onProgress, `✓ Removed ${name}`, "success")
removed = true
removedCount++
} catch (error) {
report(onProgress, `✗ Failed to remove ${name}`, "error")
errors.push(error instanceof Error ? error.message : String(error))
}
}
if (removed) {
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson() as PluginsJson | null
if (pluginsJson?.plugins) {
pluginsJson.plugins = pluginsJson.plugins.filter(
(plugin) =>
!names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source),
)
writePluginsJson(pluginsJson as Record<string, unknown>)
}
report(onProgress, "Updated quartz.lock.json", "info")
return { success: errors.length === 0, installed: removedCount, failed: errors.length, errors }
}
export async function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No plugins installed"
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
const pluginsToUpdate = names ?? Object.keys(lockfile.plugins)
const updatedPlugins: Array<{ name: string; pluginDir: string }> = []
const errors: string[] = []
let failed = 0
for (const name of pluginsToUpdate) {
const entry = lockfile.plugins[name]
if (!entry) {
report(onProgress, `${name} is not installed`, "warning")
continue
}
const pluginDir = path.join(PLUGINS_DIR, name)
if (!fs.existsSync(pluginDir)) {
report(onProgress, `${name} directory missing. Run 'npx quartz plugin install'.`, "warning")
continue
}
try {
report(onProgress, `→ Updating ${name}...`, "info")
await runGit(["fetch", "--depth", "1", "origin"], pluginDir)
await runGit(["reset", "--hard", "origin/HEAD"], pluginDir)
const newCommit = await getGitCommitAsync(pluginDir)
if (newCommit !== entry.commit) {
entry.commit = newCommit
entry.installedAt = new Date().toISOString()
updatedPlugins.push({ name, pluginDir })
report(onProgress, `✓ Updated ${name} to ${shortCommit(newCommit)}`, "success")
} else {
report(onProgress, `${name} already up to date`, "info")
}
} catch (error) {
const message = `✗ Failed to update ${name}: ${error instanceof Error ? error.message : error}`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (updatedPlugins.length > 0) {
report(onProgress, "→ Rebuilding updated plugins...", "info")
for (const { name, pluginDir } of updatedPlugins) {
if (await buildPlugin(pluginDir, name, onProgress)) {
report(onProgress, `${name} rebuilt`, "success")
}
}
await regeneratePluginIndex()
}
writeLockfile(lockfile)
report(onProgress, "Updated quartz.lock.json", "info")
return {
success: failed === 0,
updated: updatedPlugins.map((plugin) => plugin.name),
failed,
errors,
}
}
export async function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "✗ No quartz.lock.json found. Cannot restore."
report(onProgress, message, "error")
report(
onProgress,
"Run 'npx quartz plugin add <repo>' to install plugins from scratch.",
"info",
)
return { success: false, errors: [message] }
}
report(onProgress, "→ Restoring plugins from lockfile...", "info")
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
let installed = 0
let failed = 0
const restoredPlugins: Array<{ name: string; pluginDir: string }> = []
const errors: string[] = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
report(onProgress, `${name}: directory exists, skipping`, "warning")
continue
}
try {
report(
onProgress,
`${name}: cloning ${entry.resolved}@${shortCommit(entry.commit)}...`,
"info",
)
await runGit(["clone", entry.resolved, pluginDir])
await runGit(["checkout", entry.commit], pluginDir)
report(onProgress, `${name} restored`, "success")
restoredPlugins.push({ name, pluginDir })
installed++
} catch (error) {
report(onProgress, `${name}: failed to restore`, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (restoredPlugins.length > 0) {
report(onProgress, "→ Building restored plugins...", "info")
for (const { name, pluginDir } of restoredPlugins) {
if (!(await buildPlugin(pluginDir, name, onProgress))) {
failed++
installed--
} else {
report(onProgress, `${name} built`, "success")
}
}
await regeneratePluginIndex()
}
if (failed === 0) {
report(onProgress, `✓ Restored ${installed} plugin(s)`, "success")
} else {
report(onProgress, `⚠ Restored ${installed} plugin(s), ${failed} failed`, "warning")
}
return { success: failed === 0, installed, failed, errors }
}

View File

@ -1,175 +0,0 @@
declare module "../plugin-data.js" {
export function configExists(): boolean
export function createConfigFromDefault(): Record<string, unknown>
}
declare module "@opentui/core" {
export type SelectOption = { name: string; description: string; value?: any }
export type TabSelectOption = { name: string; description: string; value?: any }
export interface CliRendererOptions {
exitOnCtrlC?: boolean
useAlternateScreen?: boolean
}
export function createCliRenderer(options?: CliRendererOptions): Promise<unknown>
}
declare module "@opentui/react" {
export function createRoot(renderer: unknown): { render(element: unknown): void }
export function useKeyboard(
handler: (event: {
name: string
shift?: boolean
ctrl?: boolean
meta?: boolean
eventType?: string
repeated?: boolean
}) => void,
): void
export function useOnResize(callback: (width: number, height: number) => void): void
export function useTimeline(options?: {
duration?: number
loop?: boolean
autoplay?: boolean
}): unknown
export function useRenderer(): { destroy(): void; console: { show(): void } }
export function useTerminalDimensions(): { width: number; height: number }
}
declare module "../../plugin-data.js" {
export function readPluginsJson(): Record<string, unknown> | null
export function writePluginsJson(data: Record<string, unknown>): void
export function readDefaultPluginsJson(): Record<string, unknown> | null
export function readLockfile(): Record<string, unknown> | null
export function writeLockfile(lockfile: Record<string, unknown>): void
export function extractPluginName(source: string): string
export function readManifestFromPackageJson(pluginDir: string): Record<string, unknown> | null
export function parseGitSource(source: string): { name: string; url: string; ref?: string }
export function getGitCommit(pluginDir: string): string
export function getPluginDir(name: string): string
export function pluginDirExists(name: string): boolean
export function ensurePluginsDir(): void
export function getEnrichedPlugins(): Array<{
index: number
name: string
displayName: string
source: string
enabled: boolean
options: Record<string, unknown>
order: number
layout: {
position: string
priority: number
display: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
} | null
category: string | string[]
installed: boolean
locked: {
source: string
resolved: string
commit: string
installedAt: string
} | null
manifest: Record<string, unknown> | null
currentCommit: string | null
modified: boolean
}>
export function getLayoutConfig(): Record<string, unknown> | null
export function getGlobalConfig(): Record<string, unknown> | null
export function updatePluginEntry(index: number, updates: Record<string, unknown>): boolean
export function updateGlobalConfig(updates: Record<string, unknown>): boolean
export function updateLayoutConfig(layout: Record<string, unknown>): boolean
export function reorderPlugin(fromIndex: number, toIndex: number): boolean
export function removePluginEntry(index: number): boolean
export function addPluginEntry(entry: Record<string, unknown>): boolean
export function configExists(): boolean
export function createConfigFromDefault(): Record<string, unknown>
export const LOCKFILE_PATH: string
export const PLUGINS_DIR: string
export const PLUGINS_JSON_PATH: string
export const DEFAULT_PLUGINS_JSON_PATH: string
}
declare module "../../plugin-git-handlers.js" {
export function handlePluginInstall(): Promise<void>
export function handlePluginAdd(sources: string[]): Promise<void>
export function handlePluginRemove(names: string[]): Promise<void>
export function handlePluginUpdate(names?: string[]): Promise<void>
export function handlePluginRestore(): Promise<void>
export function handlePluginList(): Promise<void>
export function handlePluginEnable(names: string[]): Promise<void>
export function handlePluginDisable(names: string[]): Promise<void>
export function handlePluginConfig(name: string, options?: { set?: string }): Promise<void>
export function handlePluginCheck(): Promise<void>
}
declare module "./async-plugin-ops.js" {
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
export function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
}
declare module "../async-plugin-ops.js" {
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
export function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
}
declare module "../constants.js" {
export const version: string
export const ORIGIN_NAME: string
export const UPSTREAM_NAME: string
export const QUARTZ_SOURCE_BRANCH: string
export const QUARTZ_SOURCE_REPO: string
export const cwd: string
export const cacheDir: string
export const cacheFile: string
export const fp: string
export const contentCacheFolder: string
}

View File

@ -1,24 +0,0 @@
export interface NotificationMessage {
message: string
type: "success" | "error" | "info"
}
interface NotificationProps {
message: NotificationMessage
}
const COLOR_MAP = {
success: "green",
error: "red",
info: "cyan",
} as const
export function Notification({ message }: NotificationProps) {
return (
<box paddingX={1}>
<text>
<span fg={COLOR_MAP[message.type]}>{message.message}</span>
</text>
</box>
)
}

View File

@ -1,116 +0,0 @@
import { type SelectOption } from "@opentui/core"
import {
createConfigFromDefault,
readDefaultPluginsJson,
writePluginsJson,
} from "../../plugin-data.js"
interface SetupWizardProps {
onComplete: () => void
}
type Choice = "default" | "empty"
export function SetupWizard({ onComplete }: SetupWizardProps) {
const hasDefault = readDefaultPluginsJson() !== null
const choices: { key: Choice; label: string; description: string }[] = [
...(hasDefault
? [
{
key: "default" as Choice,
label: "Use default configuration",
description: "Copy quartz.config.default.yaml as your starting config",
},
]
: []),
{
key: "empty",
label: "Start with empty configuration",
description: "Create a minimal config with no plugins",
},
]
const selectOptions: SelectOption[] = choices.map((choice) => ({
name: choice.label,
description: choice.description,
value: choice.key,
}))
return (
<box flexDirection="column" paddingX={2} paddingY={1}>
<box flexDirection="column" marginBottom={1}>
<text>
<span fg="yellow">
<strong>No configuration found</strong>
</span>
</text>
<text>
<span fg="#888888">
quartz.config.yaml does not exist yet. How would you like to set up your configuration?
</span>
</text>
</box>
<select
options={selectOptions}
focused
onSelect={(_index: number, option: SelectOption | null) => {
if (!option) return
const choice = option.value as Choice
if (choice === "default") {
createConfigFromDefault()
} else {
writePluginsJson({
$schema: "./quartz/plugins/quartz-plugins.schema.json",
configuration: {
pageTitle: "Quartz",
enableSPA: true,
enablePopovers: true,
analytics: { provider: "plausible" },
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
theme: {
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
},
},
},
plugins: [],
layout: { groups: {}, byPageType: {} },
})
}
onComplete()
}}
/>
</box>
)
}

View File

@ -1,46 +0,0 @@
const KEYBINDS: Record<string, string[]> = {
Plugins: [
"↑↓ navigate",
"e enable",
"d disable",
"a add",
"r remove",
"i install",
"u update",
"o options",
"s sort",
"Tab switch tab",
"q quit",
],
Layout: [
"↑↓ navigate",
"←→ move zone",
"K/J reorder",
"m move",
"p priority",
"v display",
"c condition",
"x remove",
"g groups",
"t page-types",
"Tab switch tab",
"q quit",
],
Settings: ["↑↓ navigate", "Enter edit", "Tab switch tab", "q quit"],
}
interface StatusBarProps {
activeTab: string
}
export function StatusBar({ activeTab }: StatusBarProps) {
const hints = KEYBINDS[activeTab] ?? []
return (
<box border borderStyle="single" paddingX={1}>
<text>
<span fg="#888888">{hints.join(" │ ")}</span>
</text>
</box>
)
}

View File

@ -1,32 +0,0 @@
import { useState, useCallback } from "react"
import { getLayoutConfig, updateLayoutConfig } from "../../plugin-data.js"
export interface LayoutZone {
position: string
components: Array<{
pluginName: string
displayName: string
priority: number
display: string
condition?: string
group?: string
}>
}
export function useLayout() {
const [layout, setLayout] = useState<Record<string, unknown> | null>(() => getLayoutConfig())
const refresh = useCallback(() => {
setLayout(getLayoutConfig())
}, [])
const save = useCallback(
(newLayout: Record<string, unknown>) => {
updateLayoutConfig(newLayout)
refresh()
},
[refresh],
)
return { layout, refresh, save }
}

View File

@ -1,133 +0,0 @@
import { useState, useCallback } from "react"
import {
getEnrichedPlugins,
updatePluginEntry,
addPluginEntry,
removePluginEntry,
reorderPlugin,
} from "../../plugin-data.js"
export interface EnrichedPlugin {
index: number
name: string
displayName: string
source: string
enabled: boolean
options: Record<string, unknown>
order: number
layout: {
position: string
priority: number
display: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
} | null
category: string | string[]
installed: boolean
locked: {
source: string
resolved: string
commit: string
installedAt: string
} | null
manifest: Record<string, unknown> | null
currentCommit: string | null
modified: boolean
}
export function usePlugins() {
const [plugins, setPlugins] = useState<EnrichedPlugin[]>(() => getEnrichedPlugins())
const refresh = useCallback(() => {
setPlugins(getEnrichedPlugins())
}, [])
const toggleEnabled = useCallback(
(index: number) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { enabled: !plugin.enabled })
refresh()
},
[plugins, refresh],
)
const setPluginOrder = useCallback(
(index: number, order: number) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { order })
refresh()
},
[plugins, refresh],
)
const setPluginOptions = useCallback(
(index: number, key: string, value: unknown) => {
const plugin = plugins[index]
if (!plugin) return
const newOptions = { ...plugin.options, [key]: value }
updatePluginEntry(plugin.index, { options: newOptions })
refresh()
},
[plugins, refresh],
)
const removePlugin = useCallback(
(index: number) => {
const plugin = plugins[index]
if (!plugin) return false
const result = removePluginEntry(plugin.index)
if (result) refresh()
return result
},
[plugins, refresh],
)
const addPlugin = useCallback(
(source: string) => {
const entry = {
source,
enabled: true,
options: {},
order: 50,
}
const result = addPluginEntry(entry)
if (result) refresh()
return result
},
[refresh],
)
const movePlugin = useCallback(
(fromIndex: number, toIndex: number) => {
const result = reorderPlugin(fromIndex, toIndex)
if (result) refresh()
return result
},
[refresh],
)
const updateLayout = useCallback(
(index: number, layout: EnrichedPlugin["layout"]) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { layout })
refresh()
},
[plugins, refresh],
)
return {
plugins,
refresh,
toggleEnabled,
setPluginOrder,
setPluginOptions,
removePlugin,
addPlugin,
movePlugin,
updateLayout,
}
}

View File

@ -1,20 +0,0 @@
import { useState, useCallback } from "react"
import { getGlobalConfig, updateGlobalConfig } from "../../plugin-data.js"
export function useSettings() {
const [config, setConfig] = useState<Record<string, unknown> | null>(() => getGlobalConfig())
const refresh = useCallback(() => {
setConfig(getGlobalConfig())
}, [])
const updateField = useCallback(
(key: string, value: unknown) => {
updateGlobalConfig({ [key]: value })
refresh()
},
[refresh],
)
return { config, refresh, updateField }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,781 +0,0 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { useKeyboard } from "@opentui/react"
import { useSettings } from "../hooks/useSettings.js"
import { readDefaultPluginsJson } from "../../plugin-data.js"
type View = "list" | "edit-string" | "edit-boolean" | "edit-enum" | "edit-array" | "edit-color"
interface SettingsPanelProps {
notify: (message: string, type?: "success" | "error" | "info") => void
onFocusChange: (focused: boolean) => void
}
interface FlatEntry {
keyPath: string[]
displayKey: string
value: unknown
depth: number
isObject: boolean
}
interface FieldSchema {
type: "boolean" | "string" | "enum" | "array" | "number" | "object" | "color"
enumValues?: string[]
description?: string
}
function getFieldSchema(keyPath: string[]): FieldSchema {
const path = keyPath.join(".")
if (["enableSPA", "enablePopovers", "theme.cdnCaching"].includes(path)) {
return { type: "boolean" }
}
if (path === "theme.fontOrigin") {
return { type: "enum", enumValues: ["googleFonts", "local"] }
}
if (path === "defaultDateType") {
return { type: "enum", enumValues: ["created", "modified", "published"] }
}
if (path === "ignorePatterns") {
return { type: "array" }
}
if (path.match(/^theme\.colors\.(lightMode|darkMode)\./)) {
return { type: "color" }
}
return { type: "string" }
}
const HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
const CSS_COLOR_FUNCTION_REGEX = /^(rgba?|hsla?|hwb|lab|lch|color)\(.+\)$/i
function isValidColorValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (HEX_COLOR_REGEX.test(trimmed)) return true
return CSS_COLOR_FUNCTION_REGEX.test(trimmed)
}
function flattenConfig(
obj: Record<string, unknown>,
prefix: string[] = [],
depth = 0,
): FlatEntry[] {
const entries: FlatEntry[] = []
for (const [key, value] of Object.entries(obj)) {
const keyPath = [...prefix, key]
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
entries.push({
keyPath,
displayKey: key,
value,
depth,
isObject: true,
})
entries.push(...flattenConfig(value as Record<string, unknown>, keyPath, depth + 1))
} else {
entries.push({
keyPath,
displayKey: key,
value,
depth,
isObject: false,
})
}
}
return entries
}
function parseJsonOrString(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return value
}
}
function setNestedValue(obj: Record<string, unknown>, keyPath: string[], value: unknown): void {
let current = obj
for (let i = 0; i < keyPath.length - 1; i++) {
if (!(keyPath[i] in current) || typeof current[keyPath[i]] !== "object") {
current[keyPath[i]] = {}
}
current = current[keyPath[i]] as Record<string, unknown>
}
current[keyPath[keyPath.length - 1]] = value
}
function formatStringValue(value: unknown): string {
if (typeof value === "string") return JSON.stringify(value)
if (value === null) return "null"
if (value === undefined) return "undefined"
return String(value)
}
function getDefaultSettingValue(
defaultConfig: Record<string, unknown> | null,
keyPath: string[],
): unknown | undefined {
if (!defaultConfig) return undefined
let current: unknown = defaultConfig
for (const key of keyPath) {
if (current === null || current === undefined || typeof current !== "object") return undefined
current = (current as Record<string, unknown>)[key]
}
return current
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (a === null || b === null) return false
if (typeof a !== typeof b) return false
if (typeof a !== "object") return false
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
return a.every((val, i) => deepEqual(val, b[i]))
}
const keysA = Object.keys(a as Record<string, unknown>)
const keysB = Object.keys(b as Record<string, unknown>)
if (keysA.length !== keysB.length) return false
return keysA.every((key) =>
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
)
}
export function SettingsPanel({ notify, onFocusChange }: SettingsPanelProps) {
const { config, updateField } = useSettings()
const [view, setView] = useState<View>("list")
const [editingEntry, setEditingEntry] = useState<FlatEntry | null>(null)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const [highlightedBoolIndex, setHighlightedBoolIndex] = useState(0)
const [highlightedEnumIndex, setHighlightedEnumIndex] = useState(0)
const [highlightedArrayIndex, setHighlightedArrayIndex] = useState(0)
const [arrayItems, setArrayItems] = useState<string[]>([])
const [addingArrayItem, setAddingArrayItem] = useState(false)
const [editingArrayItemIndex, setEditingArrayItemIndex] = useState<number | null>(null)
const [colorError, setColorError] = useState<string | null>(null)
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const defaultConfig = useMemo(() => {
const data = readDefaultPluginsJson()
return (data?.configuration as Record<string, unknown>) ?? null
}, [])
const allEntries = useMemo(() => {
if (!config) return []
return flattenConfig(config)
}, [config])
const visibleEntries = useMemo(() => {
return allEntries.filter((entry) => {
for (let i = entry.keyPath.length - 1; i > 0; i--) {
const parentPath = entry.keyPath.slice(0, i).join(".")
if (collapsed.has(parentPath)) return false
}
return true
})
}, [allEntries, collapsed])
useEffect(() => {
if (highlightedIndex >= visibleEntries.length) {
setHighlightedIndex(Math.max(0, visibleEntries.length - 1))
}
}, [highlightedIndex, visibleEntries.length])
useEffect(() => {
if (!editingEntry) return
const schema = getFieldSchema(editingEntry.keyPath)
if (schema.type === "boolean") {
setHighlightedBoolIndex(Boolean(editingEntry.value) ? 0 : 1)
} else if (schema.type === "enum" && schema.enumValues) {
const idx = schema.enumValues.indexOf(String(editingEntry.value))
setHighlightedEnumIndex(idx >= 0 ? idx : 0)
} else if (schema.type === "array") {
setHighlightedArrayIndex(0)
}
}, [editingEntry])
const exitEdit = useCallback(() => {
setView("list")
setEditingEntry(null)
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
setColorError(null)
onFocusChange(false)
}, [onFocusChange])
const applyValue = useCallback(
(keyPath: string[], value: unknown, exitAfterSave: boolean = true) => {
if (!config) return
if (keyPath.length === 1) {
updateField(keyPath[0], value)
} else {
const fullConfig = { ...config } as Record<string, unknown>
setNestedValue(fullConfig, keyPath, value)
updateField(keyPath[0], fullConfig[keyPath[0]])
}
notify(`Set ${keyPath.join(".")}`, "success")
if (exitAfterSave) {
exitEdit()
}
},
[config, updateField, notify, exitEdit],
)
const enterEdit = useCallback(
(entry: FlatEntry) => {
const schema = getFieldSchema(entry.keyPath)
setEditingEntry(entry)
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
if (schema.type === "boolean") {
setHighlightedBoolIndex(Boolean(entry.value) ? 0 : 1)
setView("edit-boolean")
} else if (schema.type === "enum") {
if (schema.enumValues) {
const idx = schema.enumValues.indexOf(String(entry.value))
setHighlightedEnumIndex(idx >= 0 ? idx : 0)
}
setView("edit-enum")
} else if (schema.type === "array") {
setArrayItems(Array.isArray(entry.value) ? entry.value.map(String) : [])
setHighlightedArrayIndex(0)
setView("edit-array")
} else if (schema.type === "color") {
setColorError(null)
setView("edit-color")
} else {
setView("edit-string")
}
onFocusChange(true)
},
[onFocusChange],
)
useKeyboard((event) => {
if (view !== "list") return
const count = visibleEntries.length
if (count === 0) return
if (event.name === "up") {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : count - 1))
}
if (event.name === "down") {
setHighlightedIndex((prev) => (prev < count - 1 ? prev + 1 : 0))
}
if (event.name === "return") {
const entry = visibleEntries[highlightedIndex]
if (!entry) return
if (entry.isObject) {
const path = entry.keyPath.join(".")
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
} else {
enterEdit(entry)
}
}
if (event.name === "d" && event.shift) {
const entry = visibleEntries[highlightedIndex]
if (!entry) return
const defaultValue = getDefaultSettingValue(defaultConfig, entry.keyPath)
if (defaultValue === undefined) {
notify("No default available for " + entry.keyPath.join("."), "error")
return
}
if (deepEqual(entry.value, defaultValue)) {
notify(entry.keyPath.join(".") + " is already default", "info")
return
}
applyValue(entry.keyPath, defaultValue)
notify("Restored " + entry.keyPath.join(".") + " to default", "success")
}
})
useKeyboard((event) => {
if (view !== "edit-boolean" || !editingEntry) return
if (event.name === "escape") {
exitEdit()
return
}
if (event.name === "up" || event.name === "down") {
setHighlightedBoolIndex((prev) => (prev === 0 ? 1 : 0))
}
if (event.name === "return") {
const newVal = highlightedBoolIndex === 0
applyValue(editingEntry.keyPath, newVal)
}
})
useKeyboard((event) => {
if (view !== "edit-enum" || !editingEntry) return
const schema = getFieldSchema(editingEntry.keyPath)
const enumValues = schema.type === "enum" ? (schema.enumValues ?? []) : []
if (event.name === "escape") {
exitEdit()
return
}
if (event.name === "up" || event.name === "down") {
const len = enumValues.length
if (len === 0) return
setHighlightedEnumIndex((prev) =>
event.name === "up" ? (prev > 0 ? prev - 1 : len - 1) : prev < len - 1 ? prev + 1 : 0,
)
}
if (event.name === "return") {
const selected = enumValues[highlightedEnumIndex]
if (selected !== undefined) {
applyValue(editingEntry.keyPath, selected)
}
}
})
useKeyboard((event) => {
if (view !== "edit-array" || !editingEntry) return
if (addingArrayItem || editingArrayItemIndex !== null) {
if (event.name === "escape") {
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
}
return
}
if (event.name === "escape") {
exitEdit()
return
}
const count = arrayItems.length
if (event.name === "up" && count > 0 && !event.shift) {
setHighlightedArrayIndex((prev) => (prev > 0 ? prev - 1 : count - 1))
}
if (event.name === "down" && count > 0 && !event.shift) {
setHighlightedArrayIndex((prev) => (prev < count - 1 ? prev + 1 : 0))
}
if (event.name === "n") {
setAddingArrayItem(true)
}
if (event.name === "return" && count > 0) {
setEditingArrayItemIndex(highlightedArrayIndex)
}
if (event.name === "x" && count > 0) {
const nextItems = arrayItems.filter((_, i) => i !== highlightedArrayIndex)
setArrayItems(nextItems)
setHighlightedArrayIndex(Math.max(0, Math.min(highlightedArrayIndex, nextItems.length - 1)))
applyValue(editingEntry.keyPath, nextItems, false)
}
if (event.name === "up" && event.shift && highlightedArrayIndex > 0) {
const nextItems = [...arrayItems]
const idx = highlightedArrayIndex
;[nextItems[idx - 1], nextItems[idx]] = [nextItems[idx], nextItems[idx - 1]]
setArrayItems(nextItems)
setHighlightedArrayIndex(idx - 1)
applyValue(editingEntry.keyPath, nextItems, false)
}
if (event.name === "down" && event.shift && highlightedArrayIndex < count - 1) {
const nextItems = [...arrayItems]
const idx = highlightedArrayIndex
;[nextItems[idx], nextItems[idx + 1]] = [nextItems[idx + 1], nextItems[idx]]
setArrayItems(nextItems)
setHighlightedArrayIndex(idx + 1)
applyValue(editingEntry.keyPath, nextItems, false)
}
})
useKeyboard((event) => {
if (view !== "edit-string" && view !== "edit-color") return
if (event.name === "escape") exitEdit()
})
if (!config) {
return (
<box padding={1}>
<text>
<span fg="#888888">No configuration found. Run `quartz create` first.</span>
</text>
</box>
)
}
const renderTree = (dimmed: boolean) => {
const baseFg = dimmed ? "#666666" : "#888888"
const highlightFg = dimmed ? "#AAAAAA" : "#FFFFFF"
const renderedEntries = visibleEntries.map((entry, idx) => {
const indent = " ".repeat(entry.depth)
const isHighlighted = idx === highlightedIndex
const marker = isHighlighted ? "▸ " : " "
if (entry.isObject) {
const isCollapsed = collapsed.has(entry.keyPath.join("."))
const arrow = isCollapsed ? "▸" : "▾"
return (
<text key={entry.keyPath.join(".")}>
{isHighlighted ? (
<span fg={highlightFg}>
<strong>
{marker}
{indent}
{entry.displayKey}: {arrow}
</strong>
</span>
) : (
<span fg={baseFg}>
{marker}
{indent}
{entry.displayKey}: {arrow}
</span>
)}
</text>
)
}
const schema = getFieldSchema(entry.keyPath)
let valueText = ""
let valueFg: string | null = null
let swatchColor: string | null = null
if (schema.type === "boolean") {
const enabled = Boolean(entry.value)
valueText = enabled ? "● true" : "○ false"
valueFg = enabled ? "green" : "red"
} else if (schema.type === "enum") {
valueText = String(entry.value ?? "")
} else if (schema.type === "array") {
const length = Array.isArray(entry.value) ? entry.value.length : 0
valueText = `[${length} items]`
} else if (schema.type === "color") {
const colorValue = String(entry.value ?? "")
valueText = colorValue
swatchColor = isValidColorValue(colorValue) ? colorValue : null
} else {
valueText = formatStringValue(entry.value)
}
const displayFg = isHighlighted ? (valueFg ?? highlightFg) : (valueFg ?? baseFg)
const isDefault =
!entry.isObject &&
deepEqual(entry.value, getDefaultSettingValue(defaultConfig, entry.keyPath))
const defaultTag = isDefault ? " (default)" : ""
return (
<text key={entry.keyPath.join(".")}>
{isHighlighted ? (
<span fg={highlightFg}>
<strong>
{marker}
{indent}
{entry.displayKey}:
</strong>
</span>
) : (
<span fg={baseFg}>
{marker}
{indent}
{entry.displayKey}:
</span>
)}
{swatchColor ? <span fg={swatchColor}> </span> : null}
<span fg={displayFg}>
{isHighlighted ? (
<strong>
{valueText}
{defaultTag}
</strong>
) : (
<>
{valueText}
<span fg="#555555">{defaultTag}</span>
</>
)}
</span>
</text>
)
})
return (
<scrollbox>
<box flexDirection="column">{renderedEntries}</box>
</scrollbox>
)
}
const renderEditPanel = () => {
if (!editingEntry) return null
const schema = getFieldSchema(editingEntry.keyPath)
const pathLabel = editingEntry.keyPath.join(".")
if (view === "edit-boolean") {
const boolItems = [
{ label: "true", isCurrent: Boolean(editingEntry.value) === true },
{ label: "false", isCurrent: Boolean(editingEntry.value) === false },
]
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<box flexDirection="column" marginTop={1}>
{boolItems.map((item, i) => {
const isHighlighted = i === highlightedBoolIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
return (
<text key={item.label}>
<span fg={fg}>
{marker}
{item.label}
{item.isCurrent ? " (current)" : ""}
</span>
</text>
)
})}
</box>
<text>
<span fg="#888888">: toggle Enter: select Esc: back</span>
</text>
</box>
)
}
if (view === "edit-enum" && schema.type === "enum") {
const enumValues = schema.enumValues ?? []
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<box flexDirection="column" marginTop={1}>
{enumValues.map((value, i) => {
const isHighlighted = i === highlightedEnumIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
const currentTag = value === String(editingEntry.value) ? " (current)" : ""
return (
<text key={value}>
<span fg={fg}>
{marker}
{value}
{currentTag}
</span>
</text>
)
})}
</box>
<text>
<span fg="#888888">: navigate Enter: select Esc: back</span>
</text>
</box>
)
}
if (view === "edit-array") {
const hasItems = arrayItems.length > 0
const controlsLabel = "n: add │ x: delete │ Shift+↑/↓: reorder │ Esc: back"
const editingLabel = editingArrayItemIndex !== null ? "Edit item:" : "New item:"
const placeholder =
editingArrayItemIndex !== null ? (arrayItems[editingArrayItemIndex] ?? "") : "pattern"
const showInput = addingArrayItem || editingArrayItemIndex !== null
const arrayLines = hasItems
? arrayItems.map((value, index) => {
const isHighlighted = index === highlightedArrayIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
return (
<text key={`${index}-${value}`}>
<span fg={fg}>
{marker}
{JSON.stringify(value)}
</span>
</text>
)
})
: [
<text key="__empty__">
<span fg="#888888">(no items)</span>
</text>,
]
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<scrollbox>
<box flexDirection="column">{arrayLines}</box>
</scrollbox>
<box marginTop={1}>
<text>{editingLabel} </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={placeholder}
focused={showInput}
onSubmit={(value: string) => {
const trimmed = value.trim()
if (!trimmed) {
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
return
}
if (editingArrayItemIndex !== null) {
const nextItems = [...arrayItems]
nextItems[editingArrayItemIndex] = trimmed
setArrayItems(nextItems)
applyValue(editingEntry.keyPath, nextItems, false)
setEditingArrayItemIndex(null)
return
}
const nextItems = [...arrayItems, trimmed]
setArrayItems(nextItems)
setHighlightedArrayIndex(Math.max(0, nextItems.length - 1))
applyValue(editingEntry.keyPath, nextItems, false)
setAddingArrayItem(false)
}}
/>
</box>
</box>
<text>
<span fg="#888888">{controlsLabel}</span>
</text>
</box>
)
}
if (view === "edit-color") {
const currentValue = String(editingEntry.value ?? "")
const showSwatch = isValidColorValue(currentValue)
const errorText = colorError ?? ""
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<text>
<span fg="#888888">Current: {currentValue}</span>
{showSwatch ? <span fg={currentValue}> </span> : null}
</text>
<box marginTop={1}>
<text>Value: </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={currentValue}
focused
onSubmit={(value: string) => {
const trimmed = value.trim()
if (!isValidColorValue(trimmed)) {
setColorError(
"Invalid color. Use #RGB, #RRGGBB, #RRGGBBAA, or a CSS color function like rgba(...)",
)
return
}
setColorError(null)
applyValue(editingEntry.keyPath, trimmed)
}}
/>
</box>
</box>
<text>
<span fg={errorText ? "red" : "#888888"}>{errorText}</span>
</text>
<text>
<span fg="#888888">Enter: save Esc: cancel</span>
</text>
</box>
)
}
if (view === "edit-string") {
const currentLabel = formatStringValue(editingEntry.value)
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<text>
<span fg="#888888">Current: {currentLabel}</span>
</text>
<box marginTop={1} flexDirection="row">
<text>Value: </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={currentLabel}
focused
onSubmit={(value: string) => {
const parsed = parseJsonOrString(value)
applyValue(editingEntry.keyPath, parsed)
}}
/>
</box>
</box>
<text>
<span fg="#888888">Enter: save Esc: cancel</span>
</text>
</box>
)
}
return null
}
if (view !== "list" && editingEntry) {
return (
<box flexDirection="row" paddingX={1} gap={1} flexGrow={1}>
<box flexDirection="column" flexGrow={1}>
<text>
<strong>Global Configuration</strong>
</text>
<box
flexDirection="column"
border
borderStyle="single"
paddingX={1}
marginTop={1}
flexGrow={1}
>
{renderTree(true)}
</box>
</box>
<box flexDirection="column" border borderStyle="single" paddingX={1} flexGrow={1}>
{renderEditPanel()}
</box>
</box>
)
}
return (
<box flexDirection="column" paddingX={1} flexGrow={1}>
<text>
<strong>Global Configuration</strong>
</text>
<box
flexDirection="column"
border
borderStyle="single"
paddingX={1}
marginTop={1}
flexGrow={1}
>
{renderTree(false)}
</box>
<text>
<span fg="#888888">: navigate Enter: edit/expand Shift+D: restore default</span>
</text>
</box>
)
}

View File

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"lib": ["ESNext", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": true,
"outDir": "dist",
"rootDir": "..",
"declaration": false,
"types": ["node", "react"]
},
"include": ["*.tsx", "**/*.tsx", "**/*.ts", "cli-modules.d.ts"]
}