c5aba094bd
- AI Tools moves from a Settings section into a modal behind a button at the Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs, /settings#ai-tools); rows get an enable switch, Default badge, and an Update button for every tool whose catalog names an updater (no version lookup on open); not-installed tools fold behind a disclosure, a finished job's log folds to one line; shared AiToolIcon - codex updates through its own `codex update`, openclaw/hermes gain `update --yes`; update-all runs every installed updater and skips an npm one only when npm does not manage the binary - Add-lane tool picker is one button showing the chosen tool that opens the enabled tools as buttons - Claude Code / Codex AgentHub sub-tab folded into Overview (home editor, hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME; HomeDirSetting shows the saved value only while the field differs - Preview/Raw toggle for markdown files in the config FileViewer and the Changes panel's whole-file view (MarkdownPreview: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) - docs and the CLAUDE.md marker updated
131 lines
4.6 KiB
TypeScript
131 lines
4.6 KiB
TypeScript
/**
|
|
* @file HomeDirSetting.tsx
|
|
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
|
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
|
|
* server's refusal inline, the saved value while the field differs from it,
|
|
* and a warning when the real
|
|
* environment sets the variable (the environment wins at the next start, so a
|
|
* saved value would not survive a restart). The server validates and persists;
|
|
* this component only loads, submits and reports.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { AlertTriangle, FolderOpen } from "lucide-react";
|
|
|
|
export interface HomeDirValue {
|
|
value: string;
|
|
envOwned: boolean;
|
|
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
|
|
warning?: string | null;
|
|
}
|
|
|
|
interface Props {
|
|
/** `<section id>` — a deep-link target. */
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
/** Optional second line under the description. */
|
|
note?: string;
|
|
placeholder: string;
|
|
load: () => Promise<HomeDirValue>;
|
|
save: (path: string) => Promise<HomeDirValue>;
|
|
}
|
|
|
|
/**
|
|
* An editable home directory with its own load/save cycle.
|
|
* @returns The section, or its skeleton-free shell while loading.
|
|
*/
|
|
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
|
|
const { t } = useTranslation("settings");
|
|
const [current, setCurrent] = useState<HomeDirValue | null>(null);
|
|
const [input, setInput] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
load()
|
|
.then((v) => {
|
|
if (!live) return;
|
|
setCurrent(v);
|
|
setInput(v.value);
|
|
})
|
|
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// `load` is a stable api call per mount; re-running on identity would loop.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
if (!current || input === current.value) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const next = await save(input);
|
|
setCurrent(next);
|
|
setInput(next.value);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section id={id} className="scroll-mt-24">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
|
{title}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-1">{description}</p>
|
|
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
|
|
|
|
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
aria-label={title}
|
|
onChange={(e) => {
|
|
setInput(e.target.value);
|
|
setError(null);
|
|
}}
|
|
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
|
placeholder={placeholder}
|
|
/>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving || !current || input === current.value}
|
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
|
{/* The saved value, only while the field says something else. */}
|
|
{current && input !== current.value && (
|
|
<p className="text-xs text-fg-muted">
|
|
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
|
</p>
|
|
)}
|
|
{current?.envOwned && (
|
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
|
{t("homeDir.envOwned")}
|
|
</p>
|
|
)}
|
|
{current?.warning && (
|
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
|
{current.warning}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|