mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
refactor: extract TUI to standalone plugin repository
This commit is contained in:
parent
22f63e360d
commit
e4666dfa72
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" },
|
||||
})
|
||||
@ -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 />)
|
||||
@ -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 }
|
||||
}
|
||||
175
quartz/cli/tui/cli-modules.d.ts
vendored
175
quartz/cli/tui/cli-modules.d.ts
vendored
@ -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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user