diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 5d35558c..65139742 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -32,6 +32,11 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + # GH_PAT must be a PAT (classic or fine-grained) with read access to + # all private submodule repos in the Command-IDE org. The default + # GITHUB_TOKEN is scoped to this repo only and cannot clone private + # submodules owned by the same organisation. + token: ${{ secrets.GH_PAT || github.token }} - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml index f63c6bb3..7e8fa9e3 100644 --- a/.github/workflows/security-review.yml +++ b/.github/workflows/security-review.yml @@ -46,6 +46,11 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + # GH_PAT must be a PAT (classic or fine-grained) with read access to + # all private submodule repos in the Command-IDE org. The default + # GITHUB_TOKEN is scoped to this repo only and cannot clone private + # submodules owned by the same organisation. + token: ${{ secrets.GH_PAT || github.token }} - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.gitmodules b/.gitmodules index 04eefbcd..4eca5afd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "packages/git"] path = packages/git url = https://github.com/Command-IDE/git +[submodule "packages/ai-plugin"] + path = packages/ai-plugin + url = https://github.com/Command-IDE/ai-plugin diff --git a/app/app.go b/app/app.go index cd6e7939..68769049 100644 --- a/app/app.go +++ b/app/app.go @@ -184,6 +184,24 @@ func (a *App) SetTerminalCwd(id string, path string) { } } +func (a *App) TerminalInput(id string, data string) { + a.mu.Lock() + t, ok := a.terminals[id] + a.mu.Unlock() + if ok { + t.WriteInput(data) + } +} + +func (a *App) ResizeTerminal(id string, cols int, rows int) { + a.mu.Lock() + t, ok := a.terminals[id] + a.mu.Unlock() + if ok { + t.Resize(cols, rows) + } +} + // ─── File & editor ──────────────────────────────────────────────────────────── func (a *App) ReadFile(path string) (string, error) { @@ -202,6 +220,19 @@ func (a *App) DeleteFile(path string) error { return os.Remove(path) } func (a *App) GetFileLanguage(path string) string { return detectLanguage(path) } +// ExecSilent runs an arbitrary command in cwd, captures stdout, and never +// shows a console window on Windows. This is generic infrastructure — any +// plugin-driven feature can call it; the app has no knowledge of what is run. +func (a *App) ExecSilent(cwd string, name string, args []string) (string, error) { + cmd := exec.Command(name, args...) + if cwd != "" { + cmd.Dir = cwd + } + term.NoWindow(cmd) + out, err := cmd.Output() + return string(out), err +} + func (a *App) SelectDirectory() string { path, _ := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{ Title: "Select Directory", diff --git a/app/frontend/src/App.scss b/app/frontend/src/App.scss index 832c08e5..1419414c 100644 --- a/app/frontend/src/App.scss +++ b/app/frontend/src/App.scss @@ -336,6 +336,11 @@ font-size: 12px; padding: 4px 0; backdrop-filter: blur(12px); + scrollbar-width: none; +} + +.completion-menu::-webkit-scrollbar { + display: none; } .completion-item { diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 9b9a354b..3b9a81e8 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -12,8 +12,8 @@ import PortsTab from './components/PortsTab' import PerfTab from './components/PerfTab' import PluginStore from './plugins/PluginStore' import FullscreenIDE from './fullscreen/FullscreenIDE' -import { loadInstalledPlugins } from './plugins' -import type { Plugin, PluginContext } from './plugins' +import { buildInstalledPluginCommandMap, loadInstalledPlugins, bootstrapBuiltins } from './plugins' +import type { InstalledPluginCommand, Plugin, PluginContext } from './plugins' import { Tab, ProbItem, OpenFilePayload, OpenDatabasePayload, OpenPreviewPayload, OpenProblemsPayload, AppConfig } from './types' import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' import { GetAppConfig, SaveSession, LoadSession, ReadFile, GetFileLanguage, GetTerminalCwd, ScanProblems, SaveCustomTheme, SaveAppConfig, CheckForUpdate, PerformUpdate } from '../wailsjs/go/main/App' @@ -158,7 +158,7 @@ function tabReducer(state: TabState, action: TabAction): TabState { } case 'open-tab': { - // Generic singleton-style tab (ports, perf, plugins, notepad, git, claude, etc.) + // Generic singleton-style tab (ports, perf, plugins, and plugin tabs) // fullscreen (/fs) tabs are NOT singletons — each invocation opens its own tab at its own cwd if (action.tabType !== 'fullscreen') { const existing = state.tabs.find(t => t.type === action.tabType) @@ -237,6 +237,65 @@ const initialState: TabState = { tabs: [initialTab], activeId: initialTab.id } const DIVIDER_PX = 4 +interface PluginErrorBoundaryProps { + pluginName: string + children: React.ReactNode +} + +interface PluginErrorBoundaryState { + error: Error | null +} + +class PluginErrorBoundary extends React.Component { + state: PluginErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): PluginErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error) { + console.error(`[plugins] ${this.props.pluginName} crashed`, error) + } + + render() { + if (this.state.error) { + return ( +
+
+
+ Plugin Error +
+
+ {this.props.pluginName} failed to render +
+
+ {this.state.error.message} +
+
+
+ ) + } + + return this.props.children + } +} + export default function App() { const [state, dispatch] = useReducer(tabReducer, initialState) const { tabs, activeId } = state @@ -252,6 +311,7 @@ export default function App() { const [terminalCwds, setTerminalCwds] = useState>({}) // tabType → Plugin; rebuilt whenever a plugin is installed/uninstalled const [plugins, setPlugins] = useState>({}) + const [pluginCommands, setPluginCommands] = useState>({}) const contentRef = useRef(null) const dragging = useRef(false) @@ -259,10 +319,14 @@ export default function App() { // ── plugin loader ───────────────────────────────────────────────────────────── const reloadPlugins = useCallback(async () => { if (!__PLUGINS__) return + bootstrapBuiltins() const loaded = await loadInstalledPlugins().catch(() => [] as Plugin[]) const map: Record = {} - for (const p of loaded) { if (p.tabType) map[p.tabType] = p } + for (const p of loaded) { + if (p.tabType) map[p.tabType] = p + } setPlugins(map) + setPluginCommands(buildInstalledPluginCommandMap(loaded)) }, []) useEffect(() => { reloadPlugins() }, [reloadPlugins]) @@ -458,13 +522,13 @@ export default function App() { EventsOn('app:open-tab', (...args: any[]) => { const p = args[0] as { type: string; title: string; terminalId?: string; cwd?: string } if (!p?.type) return - if (!__PLUGINS__ && (p.type === 'plugins' || p.type in {'git':1,'notepad':1,'claude':1})) return + if (!__PLUGINS__ && p.type === 'plugins') return dispatch({ type: 'open-tab', tabType: p.type, title: p.title, terminalId: p.terminalId, cwd: p.cwd }) }) return () => EventsOff('app:open-tab') }, []) - // terminal:open-plugin-tab — window CustomEvent from Terminal.tsx for /git, /note, /claude, etc. + // terminal:open-plugin-tab — window CustomEvent from Terminal.tsx for installed plugin commands. useEffect(() => { const handler = (e: Event) => { if (!__PLUGINS__) return @@ -663,6 +727,7 @@ export default function App() { xtermTheme={resolvedTheme.xtermTheme} initialCwd={tab.initialCwd} defaultZoom={currentZoom} + pluginCommands={pluginCommands} onCwdChange={cwd => setTerminalCwds(prev => ({ ...prev, [tab.id]: cwd }))} /> ) @@ -743,7 +808,7 @@ export default function App() { /> ) } - // Plugin tabs (git, notepad, claude, external plugins) + // Plugin tabs (loaded from installed plugin metadata) if (!__PLUGINS__) return null const plugin = plugins[tab.type] if (plugin?.TabComponent) { @@ -757,7 +822,11 @@ export default function App() { : undefined, openFile: (path: string) => handleOpenFileAtLine(path, 0, 0), } - return + return ( + + + + ) } return null } diff --git a/app/frontend/src/components/Problems.scss b/app/frontend/src/components/Problems.scss index cc539958..1f50163b 100644 --- a/app/frontend/src/components/Problems.scss +++ b/app/frontend/src/components/Problems.scss @@ -14,12 +14,19 @@ .prob-header { display: flex; align-items: center; - gap: 10px; - padding: 10px 16px; + gap: 8px; + padding: 10px 14px; border-bottom: 1px solid var(--border-color); flex-shrink: 0; } +.prob-header-icon { + display: flex; + align-items: center; + color: var(--accent); + flex-shrink: 0; +} + .prob-title { font-weight: 600; font-size: 11px; @@ -30,9 +37,13 @@ } .prob-sources { - font-size: 11px; + font-size: 10.5px; color: var(--info-bar-color); white-space: nowrap; + background: var(--surface-raised); + padding: 2px 7px; + border-radius: var(--r-xl); + border: 1px solid var(--sep); } .prob-spacer { flex: 1; } @@ -40,34 +51,56 @@ .prob-summary { display: flex; align-items: center; - gap: 8px; + gap: 10px; font-size: 12px; white-space: nowrap; } -.prob-count-err { color: var(--color-error); } -.prob-count-warn { color: var(--color-warning); } -.prob-count-ok { color: var(--color-success); } +.prob-count-icon { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-right: 4px; + position: relative; + top: -1px; +} + +.prob-count-err { color: var(--color-error); display: flex; align-items: center; } +.prob-count-warn { color: var(--color-warning); display: flex; align-items: center; } +.prob-count-ok { color: var(--color-success); display: flex; align-items: center; gap: 4px; } .prob-count-dim { color: var(--info-bar-color); font-style: italic; } .prob-rescan { + display: flex; + align-items: center; + gap: 5px; background: transparent; border: 1px solid var(--sep); border-radius: var(--r-sm); color: var(--info-bar-color); font-size: 11px; font-family: var(--font-ui); - padding: 3px 10px; + padding: 4px 10px; cursor: pointer; transition: border-color var(--t-fast), color var(--t-fast); white-space: nowrap; flex-shrink: 0; } -.prob-rescan:hover:not(:disabled) { border-color: var(--sep-strong); color: var(--info-bar-hover-color); } +.prob-rescan:hover:not(:disabled) { + border-color: var(--sep-strong); + color: var(--info-bar-hover-color); +} .prob-rescan:disabled { opacity: 0.35; cursor: default; } -.prob-rescan.spinning { animation: spin 1s linear infinite; } -@keyframes spin { +.prob-rescan-icon { + display: flex; + align-items: center; +} +.prob-rescan.scanning .prob-rescan-icon { + animation: prob-spin 0.9s linear infinite; +} + +@keyframes prob-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @@ -77,12 +110,15 @@ display: flex; align-items: center; gap: 2px; - padding: 5px 12px; + padding: 5px 10px; border-bottom: 1px solid var(--sep); flex-shrink: 0; } .prob-filter { + display: flex; + align-items: center; + gap: 5px; background: transparent; border: none; border-radius: var(--r-sm); @@ -91,17 +127,24 @@ font-family: var(--font-ui); padding: 4px 9px; cursor: pointer; - display: flex; - align-items: center; - gap: 5px; transition: background var(--t-fast), color var(--t-fast); } .prob-filter:hover { color: var(--info-bar-hover-color); background: var(--surface-raised); } .prob-filter.active { color: var(--info-bar-hover-color); background: var(--surface-overlay); } +.prob-filter-icon { + display: flex; + align-items: center; +} +.prob-filter-icon.err { color: var(--color-error); } +.prob-filter-icon.warn { color: var(--color-warning); } + .prob-filter-count { font-size: 10px; - opacity: 0.6; + background: var(--surface-overlay); + padding: 1px 5px; + border-radius: var(--r-xl); + opacity: 0.7; } .prob-cwd { @@ -113,7 +156,7 @@ overflow: hidden; text-overflow: ellipsis; max-width: 200px; - opacity: 0.6; + opacity: 0.55; } /* ── Body ────────────────────────────────────────────────────────────────────── */ @@ -129,74 +172,155 @@ .prob-body::-webkit-scrollbar-thumb { background: var(--sep-strong); border-radius: 3px; } .prob-empty { - padding: 48px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 52px 20px; text-align: center; color: var(--info-bar-color); font-size: 13px; } +.prob-empty-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--surface-raised); +} +.prob-empty-icon svg { width: 18px; height: 18px; } +.prob-empty-icon.ok { color: var(--color-success); } +.prob-empty-icon.info { color: var(--accent); } + /* ── File groups ─────────────────────────────────────────────────────────────── */ .prob-group { - margin-bottom: 1px; + margin-bottom: 2px; } .prob-file-header { display: flex; align-items: center; - gap: 10px; - padding: 7px 16px 6px; + gap: 7px; + padding: 7px 14px 6px; background: var(--surface-raised); border-top: 1px solid var(--sep); + border-bottom: 1px solid var(--sep); +} + +.prob-file-icon { + display: flex; + align-items: center; + color: var(--info-bar-color); + flex-shrink: 0; + opacity: 0.7; } .prob-file-path { flex: 1; font-family: var(--font-mono); - font-size: 11px; - color: var(--info-bar-color); + font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: baseline; + gap: 0; + min-width: 0; +} + +.prob-file-dir { + color: var(--info-bar-color); + opacity: 0.6; +} + +.prob-file-name { + color: var(--info-bar-hover-color); + font-weight: 500; +} + +.prob-file-counts { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; } .prob-file-badge { + display: flex; + align-items: center; + gap: 3px; font-size: 10px; font-weight: 500; - padding: 2px 8px; + padding: 2px 7px 2px 5px; border-radius: var(--r-xl); flex-shrink: 0; } +.prob-file-badge svg { width: 11px; height: 11px; } .prob-file-badge.err { background: rgba(255,69,58,0.12); color: var(--color-error); } .prob-file-badge.warn { background: rgba(255,159,10,0.12); color: var(--color-warning); } /* ── Problem items ───────────────────────────────────────────────────────────── */ -.prob-file-items { padding: 2px 0; } +.prob-file-items { padding: 3px 0; } .prob-item { - padding: 5px 16px 5px 28px; + padding: 6px 14px 6px 32px; cursor: pointer; transition: background var(--t-fast); + border-left: 2px solid transparent; +} +.prob-item:hover { + background: var(--surface-raised); +} +.prob-item:hover .prob-goto { + opacity: 1; } -.prob-item:hover { background: var(--surface-raised); } +.prob-item.err:hover { border-left-color: var(--color-error); } +.prob-item.warn:hover { border-left-color: var(--color-warning); } +.prob-item.info:hover { border-left-color: var(--accent); } .prob-item-row { display: flex; - align-items: baseline; + align-items: center; gap: 7px; - flex-wrap: wrap; + min-width: 0; } -.prob-sev-dot { font-size: 10px; flex-shrink: 0; line-height: 1.6; } -.prob-sev-dot.err { color: var(--color-error); } -.prob-sev-dot.warn { color: var(--color-warning); } -.prob-sev-dot.info { color: var(--accent); } +.prob-sev-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} +.prob-sev-icon.err { color: var(--color-error); } +.prob-sev-icon.warn { color: var(--color-warning); } +.prob-sev-icon.info { color: var(--accent); } -.prob-pos { +.prob-location { font-family: var(--font-mono); - font-size: 10px; + font-size: 10.5px; color: var(--info-bar-color); flex-shrink: 0; - min-width: 42px; + white-space: nowrap; + background: var(--surface-overlay); + padding: 1px 6px; + border-radius: var(--r-sm); + display: flex; + align-items: center; + gap: 2px; +} + +.prob-location-label { + font-size: 9.5px; + opacity: 0.6; + font-family: var(--font-ui); + letter-spacing: 0.03em; +} + +.prob-location-sep { + opacity: 0.4; + margin: 0 1px; } .prob-code { @@ -204,7 +328,11 @@ font-size: 10px; color: var(--info-bar-color); flex-shrink: 0; - opacity: 0.6; + border: 1px solid var(--sep); + padding: 1px 5px; + border-radius: var(--r-xs); + opacity: 0.7; + white-space: nowrap; } .prob-msg { @@ -212,6 +340,16 @@ color: var(--info-bar-hover-color); flex: 1; min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.prob-item-end { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; } .prob-root-badge { @@ -221,16 +359,39 @@ border-radius: var(--r-xs); background: var(--accent-dim); color: var(--accent-hover); - flex-shrink: 0; white-space: nowrap; } +.prob-goto { + display: flex; + align-items: center; + color: var(--info-bar-color); + opacity: 0; + transition: opacity var(--t-fast); + flex-shrink: 0; +} + .prob-cascade { - margin-left: 17px; - margin-top: 1px; + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; font-size: 11px; color: var(--info-bar-color); - padding-bottom: 2px; + padding-left: 21px; + opacity: 0.7; +} + +.prob-cascade-indent { + display: inline-block; + width: 10px; + height: 10px; + border-left: 1px solid var(--sep-strong); + border-bottom: 1px solid var(--sep-strong); + border-bottom-left-radius: 2px; + flex-shrink: 0; + position: relative; + top: -2px; } /* ── Footer ──────────────────────────────────────────────────────────────────── */ @@ -238,12 +399,22 @@ display: flex; align-items: center; gap: 8px; - padding: 7px 16px; + padding: 7px 14px; border-top: 1px solid var(--sep); flex-shrink: 0; font-size: 11px; } -.prob-footer .err { color: var(--color-error); } -.prob-footer .warn { color: var(--color-warning); } + +.prob-footer-icon { + display: inline-flex; + align-items: center; + margin-right: 3px; + position: relative; + top: -1px; +} +.prob-footer-icon svg { width: 12px; height: 12px; } + +.prob-footer .err { color: var(--color-error); display: flex; align-items: center; } +.prob-footer .warn { color: var(--color-warning); display: flex; align-items: center; } .prob-footer .sep { color: var(--info-bar-color); opacity: 0.4; } .prob-footer .dim { color: var(--info-bar-color); } diff --git a/app/frontend/src/components/Problems.tsx b/app/frontend/src/components/Problems.tsx index 73b6771c..3182d254 100644 --- a/app/frontend/src/components/Problems.tsx +++ b/app/frontend/src/components/Problems.tsx @@ -14,30 +14,88 @@ interface Props { onOpenFile: (path: string, line: number, col: number) => void } +// ── SVG Icons ────────────────────────────────────────────────────────────────── + +const IconError = () => ( + + + + +) + +const IconWarning = () => ( + + + + + +) + +const IconInfo = () => ( + + + + + +) + +const IconCheck = () => ( + + + + +) + +const IconRefresh = () => ( + + + + +) + +const IconFile = () => ( + + + + +) + +const IconGoto = () => ( + + + +) + +const IconProblems = () => ( + + + + + +) + // ── helpers ──────────────────────────────────────────────────────────────────── -function relPath(cwd: string, file: string): string { - const norm = file.replace(/\\/g, '/') - const base = cwd.replace(/\\/g, '/').replace(/\/?$/, '/') - if (norm.startsWith(base)) return norm.slice(base.length) - // Fallback: show last 3 segments - return norm.split('/').slice(-3).join('/') +function splitPath(cwd: string, file: string): { dir: string; name: string } { + const norm = file.replace(/\\/g, '/') + const base = cwd.replace(/\\/g, '/').replace(/\/?$/, '/') + const rel = norm.startsWith(base) + ? norm.slice(base.length) + : norm.split('/').slice(-3).join('/') + const sep = rel.lastIndexOf('/') + return sep === -1 + ? { dir: '', name: rel } + : { dir: rel.slice(0, sep + 1), name: rel.slice(sep + 1) } } -/** Identify root-cause and cascade info for items in one file (sorted by line). */ function rootCauseLabel(groupItems: ProbItem[], idx: number): string | null { const errors = groupItems.filter(i => i.sev === 0) - if (errors.length < 2) return null // single error → no label needed - + if (errors.length < 2) return null const item = groupItems[idx] - if (item.sev !== 0) return null // only label errors - - if (item === errors[0]) return 'root cause' // first error = likely origin - - // Look for explicit back-reference in the message ("at line N") + if (item.sev !== 0) return null + if (item === errors[0]) return 'root cause' const ref = item.msg.match(/at line (\d+)/) if (ref) return `cascades from line ${ref[1]}` - return `may cascade from line ${errors[0].line}` } @@ -55,7 +113,6 @@ export default function Problems({ tabId, cwd, sources, items, scanning, onResca return items }, [items, filter]) - /** Group filtered items by file, each group sorted by line then col. */ const groups = useMemo(() => { const map = new Map() for (const item of filtered) { @@ -63,50 +120,62 @@ export default function Problems({ tabId, cwd, sources, items, scanning, onResca arr.push(item) map.set(item.file, arr) } - return Array.from(map.entries()) - .map(([file, its]) => ({ - file, - items: [...its].sort((a, b) => a.line - b.line || a.col - b.col), - })) + return Array.from(map.entries()).map(([file, its]) => ({ + file, + items: [...its].sort((a, b) => a.line - b.line || a.col - b.col), + })) }, [filtered]) const handleItemClick = useCallback((file: string, line: number, col: number) => { onOpenFile(file, line, col) }, [onOpenFile]) - // ── summary label ──────────────────────────────────────────────────────────── - let summaryEl: React.ReactNode - if (scanning) { - summaryEl = scanning… - } else if (items.length === 0) { - summaryEl = ✓ no problems - } else { - summaryEl = ( - <> - {errCount > 0 && {errCount} error{errCount !== 1 ? 's' : ''}} - {warnCount > 0 && {warnCount} warning{warnCount !== 1 ? 's' : ''}} - - ) - } - return (
{/* ── header ────────────────────────────────────────────────────────── */}
- ⚠ Problems + + Problems {sources.length > 0 && ( {sources.join(' · ')} )}
-
{summaryEl}
+ +
+ {scanning ? ( + scanning… + ) : items.length === 0 ? ( + + + No problems + + ) : ( + <> + {errCount > 0 && ( + + + {errCount} error{errCount !== 1 ? 's' : ''} + + )} + {warnCount > 0 && ( + + + {warnCount} warning{warnCount !== 1 ? 's' : ''} + + )} + + )} +
+
@@ -117,9 +186,11 @@ export default function Problems({ tabId, cwd, sources, items, scanning, onResca return ( @@ -135,48 +206,86 @@ export default function Problems({ tabId, cwd, sources, items, scanning, onResca {groups.length === 0 && !scanning && (
- {items.length === 0 - ? '✓ No problems found in this project' - : 'No problems match the current filter'} + + {items.length === 0 ? : } + + + {items.length === 0 + ? 'No problems found in this project' + : 'No problems match the current filter'} +
)} {groups.map(({ file, items: gItems }) => { - const hasErr = gItems.some(i => i.sev === 0) + const { dir, name } = splitPath(cwd, file) + const gErrCount = gItems.filter(i => i.sev === 0).length + const gWarnCount = gItems.filter(i => i.sev === 1).length + return (
{/* file header */}
- {relPath(cwd, file)} - {gItems.length} + + + {dir && {dir}} + {name} + +
+ {gErrCount > 0 && ( + + {gErrCount} + + )} + {gWarnCount > 0 && ( + + {gWarnCount} + + )} +
{/* items */}
{gItems.map((item, idx) => { - const rcLabel = rootCauseLabel(gItems, idx) - const isRoot = rcLabel === 'root cause' - const cascade = rcLabel && !isRoot ? rcLabel : null + const rcLabel = rootCauseLabel(gItems, idx) + const isRoot = rcLabel === 'root cause' + const cascade = rcLabel && !isRoot ? rcLabel : null + const sevClass = item.sev === 0 ? 'err' : item.sev === 1 ? 'warn' : 'info' return (
handleItemClick(item.file, item.line, item.col)} - title={`${relPath(cwd, item.file)} · line ${item.line}`} + title={`Open ${name} — line ${item.line}, col ${item.col}`} >
- - {item.sev === 0 ? '●' : item.sev === 1 ? '◐' : '○'} + + {item.sev === 0 ? : item.sev === 1 ? : } + + + Ln + {item.line} + , + Col + {item.col} - {item.line}:{item.col} {item.code && {item.code}} {item.msg} - {isRoot && root cause} +
+ {isRoot && root cause} + + + +
{cascade && ( -
↳ {cascade}
+
+ + {cascade} +
)}
) @@ -190,11 +299,25 @@ export default function Problems({ tabId, cwd, sources, items, scanning, onResca {/* ── footer ────────────────────────────────────────────────────────── */} {items.length > 0 && (
- {errCount > 0 && {errCount} error{errCount !== 1 ? 's' : ''}} - {errCount > 0 && warnCount > 0 && ·} - {warnCount > 0 && {warnCount} warning{warnCount !== 1 ? 's' : ''}} - · - {sources.join(', ')} + {errCount > 0 && ( + + + {errCount} error{errCount !== 1 ? 's' : ''} + + )} + {errCount > 0 && warnCount > 0 && ·} + {warnCount > 0 && ( + + + {warnCount} warning{warnCount !== 1 ? 's' : ''} + + )} + {sources.length > 0 && ( + <> + · + {sources.join(', ')} + + )}
)} diff --git a/app/frontend/src/components/Terminal.tsx b/app/frontend/src/components/Terminal.tsx index b0a5c8b4..c70c630a 100644 --- a/app/frontend/src/components/Terminal.tsx +++ b/app/frontend/src/components/Terminal.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' -import { getInstalledIds, isInstalled } from '../plugins/index' import { Terminal as XTerm } from '@xterm/xterm' import type { ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' +import type { InstalledPluginCommand } from '../plugins' import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime' import { CreateTerminal, @@ -18,6 +18,8 @@ import { SelectDirectory, GetCompletions, CtrlClickPath, + TerminalInput, + ResizeTerminal, } from '../../wailsjs/go/main/App' import '@xterm/xterm/css/xterm.css' @@ -27,6 +29,7 @@ interface Props { xtermTheme: ITheme initialCwd?: string defaultZoom?: number + pluginCommands?: Record onCwdChange?: (cwd: string) => void } @@ -66,38 +69,34 @@ const STATIC_SLASH_COMMANDS: { cmd: string; desc: string }[] = [ { cmd: '/help', desc: 'show all commands' }, ] -// Plugin slash commands — only shown and executable when the plugin is installed. -// Keys are all alias names for the command; value is the plugin ID + tab metadata. -const PLUGIN_COMMANDS: Record = { - 'git': { pluginId: 'git', tabType: 'git', title: 'git', displayName: 'Git Insights' }, - 'note': { pluginId: 'notepad', tabType: 'notepad', title: 'notepad', displayName: 'Notepad' }, - 'notepad': { pluginId: 'notepad', tabType: 'notepad', title: 'notepad', displayName: 'Notepad' }, - 'claude': { pluginId: 'claude', tabType: 'claude', title: 'claude', displayName: 'Claude AI' }, - 'ai': { pluginId: 'claude', tabType: 'claude', title: 'claude', displayName: 'Claude AI' }, -} +// Build the full slash command list for autocomplete from installed plugins. +function buildSlashCommands(pluginCommands: Record): { cmd: string; desc: string }[] { + const pluginEntries = Object.values(pluginCommands) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(command => ({ + cmd: `/${command.name}`, + desc: command.description, + })) -// Build the full slash command list for autocomplete, filtered by installed plugins. -function buildSlashCommands(): { cmd: string; desc: string }[] { - const installed = new Set(getInstalledIds()) - const pluginEntries: { cmd: string; desc: string }[] = [ - { cmd: '/git', desc: 'open git insights tab' }, - { cmd: '/note', desc: 'open notepad tab' }, - { cmd: '/claude', desc: 'open claude ai tab' }, - ].filter(e => { - const key = e.cmd.slice(1) - const entry = PLUGIN_COMMANDS[key] - return entry && installed.has(entry.pluginId) - }) return [...STATIC_SLASH_COMMANDS, ...pluginEntries] } -export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaultZoom = 1, onCwdChange }: Props) { +export default function Terminal({ + tabId, + active, + xtermTheme, + initialCwd, + defaultZoom = 1, + pluginCommands = {}, + onCwdChange, +}: Props) { const containerRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) const activeRef = useRef(active) useEffect(() => { activeRef.current = active }, [active]) + const ptyModeRef = useRef(false) const xtermThemeRef = useRef(xtermTheme) useEffect(() => { xtermThemeRef.current = xtermTheme }, [xtermTheme]) @@ -105,11 +104,14 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul if (termRef.current) termRef.current.options.theme = xtermTheme }, [xtermTheme]) + const cwdRef = useRef('') // tracks current cwd so plugin-tab dispatch can read it const [, setCwd] = useState('') const [fontSize, setFontSize] = useState(() => Math.round(13 * defaultZoom)) const [menu, setMenu] = useState(null) const menuRef = useRef(null) useEffect(() => { menuRef.current = menu }, [menu]) + const pluginCommandsRef = useRef(pluginCommands) + useEffect(() => { pluginCommandsRef.current = pluginCommands }, [pluginCommands]) // Refs so JSX handlers can call functions defined inside the main useEffect const applyMatchRef = useRef<((match: string) => void) | null>(null) @@ -120,12 +122,12 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul const savedInputRef = useRef('') // current input saved when entering history useEffect(() => { - GetTerminalCwd(tabId).then(p => { if (p) { setCwd(p); onCwdChange?.(p) } }).catch(() => {}) + GetTerminalCwd(tabId).then(p => { if (p) { cwdRef.current = p; setCwd(p); onCwdChange?.(p) } }).catch(() => {}) }, [tabId]) useEffect(() => { const event = `terminal:cwd:${tabId}` - EventsOn(event, (path: string) => { setCwd(path); onCwdChange?.(path) }) + EventsOn(event, (path: string) => { cwdRef.current = path; setCwd(path); onCwdChange?.(path) }) return () => EventsOff(event) }, [tabId]) @@ -306,6 +308,17 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul const outEvent = `terminal:output:${tabId}` EventsOn(outEvent, (data: string) => { term.write(data) }) + // PTY mode: switch to raw pass-through when an interactive process is running + const ptyStartEvent = `terminal:pty:start:${tabId}` + const ptyEndEvent = `terminal:pty:end:${tabId}` + EventsOn(ptyStartEvent, () => { ptyModeRef.current = true }) + EventsOn(ptyEndEvent, () => { ptyModeRef.current = false }) + + // Forward xterm resize events to the backend PTY + term.onResize(({ cols, rows }) => { + ResizeTerminal(tabId, cols, rows).catch(() => {}) + }) + const lineRef = { current: '' } // ── helpers ─────────────────────────────────────────────────────────────── @@ -361,7 +374,7 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul const line = lineRef.current if (!line.startsWith('/') || line.includes(' ')) return false - const filtered = buildSlashCommands().filter(c => c.cmd.startsWith(line)) + const filtered = buildSlashCommands(pluginCommandsRef.current).filter(c => c.cmd.startsWith(line)) if (filtered.length === 0) { setMenu(null); return true } const { h, w } = cellDims() @@ -478,6 +491,8 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul } const onContainerKeyDown = (e: KeyboardEvent) => { + if (ptyModeRef.current) return + if (e.key === 'Tab') { e.preventDefault() e.stopPropagation() @@ -544,7 +559,12 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul if (!activeRef.current) return e.preventDefault() const text = e.clipboardData?.getData('text/plain') ?? '' - if (text) processPaste(text) + if (!text) return + if (ptyModeRef.current) { + TerminalInput(tabId, text).catch(() => {}) + } else { + processPaste(text) + } } window.addEventListener('paste', onWindowPaste) @@ -553,6 +573,12 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul const undoStack: string[] = [] term.onData((data: string) => { + // PTY mode: raw pass-through — the process drives the display + if (ptyModeRef.current) { + TerminalInput(tabId, data).catch(() => {}) + return + } + // Enter if (data === '\r' || data === '\n') { setMenu(null) @@ -571,21 +597,18 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul savedInputRef.current = '' term.write('\r\n') - // Intercept plugin slash commands on the frontend so install state - // is enforced before anything reaches Go. + // Intercept installed plugin slash commands on the frontend so + // metadata-driven tabs and command handlers work before reaching Go. if (line.startsWith('/')) { const cmdName = line.slice(1).split(/\s+/)[0].toLowerCase() - const pluginCmd = PLUGIN_COMMANDS[cmdName] + const pluginCmd = pluginCommandsRef.current[cmdName] if (pluginCmd) { - if (isInstalled(pluginCmd.pluginId)) { + if (pluginCmd.handler) { + pluginCmd.handler() + } else if (pluginCmd.tabType) { window.dispatchEvent(new CustomEvent('terminal:open-plugin-tab', { detail: { type: pluginCmd.tabType, title: pluginCmd.title, terminalId: tabId }, })) - } else { - term.write( - `\x1b[38;5;203m"/${cmdName}" requires the ${pluginCmd.displayName} plugin.\x1b[0m\r\n` + - `\x1b[38;5;246mRun /plugins to open the Plugin Store and install it.\x1b[0m` - ) } // Ask Go to re-draw the prompt so the terminal stays usable. ExecuteCommand(tabId, '') @@ -678,7 +701,14 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul // Ctrl+V if (data === '\x16') { - GetClipboardText().then(text => { if (text) processPaste(text) }).catch(() => {}) + GetClipboardText().then(text => { + if (!text) return + if (ptyModeRef.current) { + TerminalInput(tabId, text).catch(() => {}) + } else { + processPaste(text) + } + }).catch(() => {}) return } @@ -718,6 +748,9 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul window.removeEventListener('paste', onWindowPaste) window.removeEventListener('plugin:execute', handlePluginExec) EventsOff(outEvent) + EventsOff(ptyStartEvent) + EventsOff(ptyEndEvent) + ptyModeRef.current = false CloseTerminal(tabId) term.dispose() termRef.current = null diff --git a/app/frontend/src/components/ZoomIndicator.tsx b/app/frontend/src/components/ZoomIndicator.tsx index ca20d09c..c50b7602 100644 --- a/app/frontend/src/components/ZoomIndicator.tsx +++ b/app/frontend/src/components/ZoomIndicator.tsx @@ -26,8 +26,6 @@ export default function ZoomIndicator({ enabled, defaultZoom = 1, onZoomChange } const stepIdx = useRef(nearestStepIdx(defaultZoom)) const defaultIdx = useRef(nearestStepIdx(defaultZoom)) const timer = useRef | null>(null) - // Gate Ctrl+Scroll to one zoom step per 120 ms (mirrors Chrome's rate-limiting). - const lastScroll = useRef(0) // Track Ctrl key state manually — WebView2 can strip e.ctrlKey from wheel events. const ctrlHeld = useRef(false) @@ -62,18 +60,18 @@ export default function ZoomIndicator({ enabled, defaultZoom = 1, onZoomChange } const onWheel = (e: WheelEvent) => { // Accept either the event's own ctrlKey flag OR our manually tracked state. if (!e.ctrlKey && !ctrlHeld.current) return - const now = Date.now() - if (now - lastScroll.current < 120) return // one step per tick - lastScroll.current = now - show(e.deltaY < 0 ? zoomIn() : zoomOut()) + // Block browser-level zoom unconditionally. Terminal and Editor each have + // their own non-passive Ctrl+Scroll handlers that zoom only themselves — + // we don't touch the global zoom level from scroll at all. + e.preventDefault() } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Control') { ctrlHeld.current = true; return } if (!e.ctrlKey && !ctrlHeld.current) return - if (e.key === '=' || e.key === '+') show(zoomIn()) - else if (e.key === '-') show(zoomOut()) - else if (e.key === '0') show(zoomReset()) + if (e.key === '=' || e.key === '+') { e.preventDefault(); show(zoomIn()) } + else if (e.key === '-') { e.preventDefault(); show(zoomOut()) } + else if (e.key === '0') { e.preventDefault(); show(zoomReset()) } } const onKeyUp = (e: KeyboardEvent) => { @@ -81,7 +79,8 @@ export default function ZoomIndicator({ enabled, defaultZoom = 1, onZoomChange } } // Use capture phase so we see the event before any child handler can stop it. - window.addEventListener('wheel', onWheel, { passive: true, capture: true }) + // Must be non-passive so we can call preventDefault() to block browser zoom. + window.addEventListener('wheel', onWheel, { passive: false, capture: true }) window.addEventListener('keydown', onKeyDown, { capture: true }) window.addEventListener('keyup', onKeyUp, { capture: true }) return () => { diff --git a/app/frontend/src/fullscreen/FileExplorer.tsx b/app/frontend/src/fullscreen/FileExplorer.tsx index dfe0410b..247a1627 100644 --- a/app/frontend/src/fullscreen/FileExplorer.tsx +++ b/app/frontend/src/fullscreen/FileExplorer.tsx @@ -18,6 +18,8 @@ interface Props { selectedPath: string onSelect: (node: FileNode) => void onRefresh: () => void + gitStatus?: Record + onAddToGitIgnore?: (node: FileNode) => void } type CtxKind = 'file' | 'area' @@ -30,7 +32,20 @@ interface CtxState { } -export default function FileExplorer({ root, selectedPath, onSelect, onRefresh }: Props) { +function gitBadge(code: string | undefined): React.ReactNode { + if (!code || code === '!') return null + if (code === 'dirty') return + const [label, mod] = + code === 'M' || code === 'T' || code === 'C' || code === 'R' ? ['M', 'modified'] : + code === 'A' ? ['A', 'added'] : + code === '?' ? ['U', 'untracked'] : + code === 'D' ? ['D', 'deleted'] : + code === 'submodule' ? ['S', 'submodule'] : + [code, 'modified'] + return {label} +} + +export default function FileExplorer({ root, selectedPath, onSelect, onRefresh, gitStatus, onAddToGitIgnore }: Props) { const [expanded, setExpanded] = useState>(new Set()) const [ctx, setCtx] = useState(null) const [renaming, setRenaming] = useState(null) @@ -124,15 +139,20 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh } }, []) // ── File-node context menu — no icons ────────────────────────────────────── - const buildFileMenu = useCallback((node: FileNode): ContextMenuItem[] => [ - { label: 'New File', action: () => handleNewFile(node) }, - { label: 'New Folder', action: () => handleNewFolder(node) }, - { divider: true }, - { label: 'Rename', action: () => startRename(node) }, - { label: 'Copy Path', action: () => handleCopyPath(node) }, - { divider: true }, - { label: 'Delete', danger: true, action: () => handleDelete(node) }, - ], [handleNewFile, handleNewFolder, startRename, handleCopyPath, handleDelete]) + const buildFileMenu = useCallback((node: FileNode): ContextMenuItem[] => { + const items: ContextMenuItem[] = [ + { label: 'New File', action: () => handleNewFile(node) }, + { label: 'New Folder', action: () => handleNewFolder(node) }, + { divider: true }, + { label: 'Rename', action: () => startRename(node) }, + { label: 'Copy Path', action: () => handleCopyPath(node) }, + ] + if (onAddToGitIgnore) { + items.push({ label: 'Add to .gitignore', action: () => onAddToGitIgnore(node) }) + } + items.push({ divider: true }, { label: 'Delete', danger: true, action: () => handleDelete(node) }) + return items + }, [handleNewFile, handleNewFolder, startRename, handleCopyPath, handleDelete, onAddToGitIgnore]) // ── Empty-area context menu — acts on root dir ───────────────────────────── const buildAreaMenu = useCallback((node: FileNode): ContextMenuItem[] => [ @@ -175,6 +195,8 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh } const isSelected = node.path === selectedPath const isDragTarget = dragOver === node.path const isRenamingThis = renaming === node.path + const gitCode = gitStatus?.[node.path] + const isIgnored = gitCode === '!' return (
@@ -183,6 +205,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh } 'fe-node', isSelected ? 'fe-node--selected' : '', isDragTarget ? 'fe-node--dragover' : '', + isIgnored ? 'fe-node--git-ignored' : '', ].join(' ')} style={{ paddingLeft: `${depth * 14 + 8}px` }} onClick={() => { @@ -226,6 +249,7 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh } ) : ( {node.name} )} + {gitBadge(gitCode)}
{node.isDir && isOpen && node.children?.map(child => renderNode(child, depth + 1))} @@ -248,9 +272,6 @@ export default function FileExplorer({ root, selectedPath, onSelect, onRefresh } className="fe-header" onContextMenu={e => openAreaCtx(e, root)} > - - - {root.name.toUpperCase()}
diff --git a/app/frontend/src/fullscreen/FileIcon.tsx b/app/frontend/src/fullscreen/FileIcon.tsx index fc55da23..0f4b43d3 100644 --- a/app/frontend/src/fullscreen/FileIcon.tsx +++ b/app/frontend/src/fullscreen/FileIcon.tsx @@ -2,7 +2,7 @@ import React from 'react' interface Props { name: string; ext: string; isDir: boolean; isOpen?: boolean } -// Doom-one / VS Code icon theme colors +// ── Color palettes — kept intact for future theme use ──────────────────────── const EXT_COLORS: Record = { ts: '#3178c6', tsx: '#3178c6', js: '#e8c84a', jsx: '#e8c84a', mjs: '#e8c84a', cjs: '#e8c84a', @@ -61,7 +61,7 @@ const FOLDER_COLORS: Record = { config: '#6d8086', configs: '#6d8086', configuration: '#6d8086', scripts: '#4eaa25', docs: '#519aba', doc: '#519aba', documentation: '#519aba', - node_modules: '#8c8c8c', + node_modules: '#e8ae4a', '.git': '#f54d27', '.github': '#888888', frontend: '#61afef', backend: '#75beff', @@ -72,42 +72,55 @@ const FOLDER_COLORS: Record = { function iconColor(name: string, ext: string): string { const lower = name.toLowerCase() if (NAMED_FILE_COLORS[lower]) return NAMED_FILE_COLORS[lower] - return EXT_COLORS[ext] ?? '#8b9bba' + return EXT_COLORS[ext] ?? '#7a8899' } function folderColor(name: string): string { - return FOLDER_COLORS[name.toLowerCase()] ?? '#51afef' + return FOLDER_COLORS[name.toLowerCase()] ?? '#7a8899' } -// File document SVG — clean minimal document with folded corner -function FileDoc({ color }: { color: string }) { +// ── Folder: outlined shape, stroke = theme color, barely-there fill tint ───── +function FolderShape({ color, open }: { color: string; open: boolean }) { + if (open) { + return ( + + {/* back panel */} + + {/* tab */} + + + ) + } return ( - - - + + ) } -// Folder SVG — classic folder shape -function FolderShape({ color, open }: { color: string; open: boolean }) { - return open ? ( - - - - - ) : ( - - +// ── File: compact outlined document, corner fold, minimal fill ─────────────── +function FileDoc({ color }: { color: string }) { + return ( + + + ) } export default function FileIcon({ name, ext, isDir, isOpen = false }: Props) { - if (isDir) { - const color = folderColor(name) - return - } - const color = iconColor(name, ext) - return + if (isDir) return + return } diff --git a/app/frontend/src/fullscreen/FullscreenIDE.tsx b/app/frontend/src/fullscreen/FullscreenIDE.tsx index 0c4e504e..d45994ef 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -1,13 +1,76 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' import MonacoEditor from '@monaco-editor/react' import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime' -import { ExplorerOpen, ExplorerGetFile, ExplorerSaveFile } from '../../wailsjs/go/main/App' +import { ExplorerOpen, ExplorerGetFile, ExplorerSaveFile, ExecSilent, ReadFile, WriteFile } from '../../wailsjs/go/main/App' +import { isInstalled } from '../plugins' import FileExplorer, { FileNode } from './FileExplorer' import IDETabBar, { OpenFile } from './IDETabBar' import MenuBar from './MenuBar' import type { AppTheme } from '../themes' import './fullscreen.scss' +// All git logic lives here in the frontend — the app has no git-specific code. +// ExecSilent is a generic infrastructure binding (like ReadFile/WriteFile). + +async function fetchGitStatusMap(cwd: string): Promise> { + try { + const root = (await ExecSilent(cwd, 'git', ['-C', cwd, 'rev-parse', '--show-toplevel'])).trim().replace(/\\/g, '/') + if (!root) return {} + + const [porcelain, gitmodulesRaw] = await Promise.all([ + ExecSilent(cwd, 'git', ['-C', cwd, 'status', '--porcelain=v1', '--ignored']), + ReadFile(root + '/.gitmodules').catch(() => ''), + ]) + + // Parse submodule paths from .gitmodules + const submodules = gitmodulesRaw + .split('\n') + .filter(l => l.trimStart().startsWith('path')) + .map(l => l.split('=')[1]?.trim() ?? '') + .filter(Boolean) + + const map: Record = {} + + for (const sub of submodules) { + map[`${root}/${sub}`] = 'submodule' + } + + for (const line of porcelain.split('\n')) { + if (line.length < 4) continue + const xy = line.slice(0, 2) + let rel = line.slice(3) + const arrow = rel.indexOf(' -> ') + if (arrow >= 0) rel = rel.slice(arrow + 4) + rel = rel.replace(/\/$/, '').replace(/^"|"$/g, '') + + let code: string + if (xy === '!!') code = '!' + else if (xy === '??') code = '?' + else { + const x = xy[0], y = xy[1] + code = (x !== ' ' && x !== '.') ? x : (y !== ' ' && y !== '.') ? y : '' + } + if (!code || !rel) continue + + const abs = `${root}/${rel}` + if (!map[abs]) map[abs] = code + + // Propagate dirty indicator up into parent directories + if (code !== '!') { + const parts = rel.split('/') + for (let i = 1; i < parts.length; i++) { + const dirAbs = `${root}/${parts.slice(0, i).join('/')}` + if (!map[dirAbs] || map[dirAbs] === '!') map[dirAbs] = 'dirty' + } + } + } + + return map + } catch { + return {} + } +} + function langFromExt(ext: string): string { const map: Record = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', @@ -41,7 +104,8 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW const [splitRatio, setSplitRatio] = useState(0.5) // ── explorer state ──────────────────────────────────────────────────────────── - const [tree, setTree] = useState(null) + const [tree, setTree] = useState(null) + const [gitStatusMap, setGitStatusMap] = useState>({}) const [explorerW, setExplorerW] = useState(220) const [explorerPos, setExplorerPos] = useState<'left' | 'right'>('left') const [collapsed, setCollapsed] = useState(false) @@ -133,7 +197,10 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW // ── tree loading ────────────────────────────────────────────────────────────── const loadTree = useCallback(async () => { if (!cwd) return + // Render the tree immediately — never block on git status setTree(await ExplorerOpen(cwd) as FileNode) + // Fetch git badges in the background; they paint in once ready + if (isInstalled('git')) fetchGitStatusMap(cwd).then(setGitStatusMap) }, [cwd]) useEffect(() => { loadTree() }, [loadTree]) @@ -481,6 +548,24 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW ) } + // ── git ignore ──────────────────────────────────────────────────────────────── + const handleAddToGitIgnore = useCallback(async (node: FileNode) => { + const cwdSlash = cwd.replace(/\\/g, '/') + const relPath = node.path.startsWith(cwdSlash + '/') + ? node.path.slice(cwdSlash.length + 1) + : node.path + const gitignorePath = cwdSlash + '/.gitignore' + + const existing = await ReadFile(gitignorePath).catch(() => '') + // Skip if already present + if (existing.split('\n').some(l => l.trim() === relPath)) return + const newContent = (existing && !existing.endsWith('\n') ? existing + '\n' : existing) + relPath + '\n' + await WriteFile(gitignorePath, newContent) + + // Refresh git status so the ignored indicator appears immediately + setGitStatusMap(await fetchGitStatusMap(cwd)) + }, [cwd]) + // ── explorer panel ──────────────────────────────────────────────────────────── const explorerPanel = (
openFile(node)} onRefresh={loadTree} + gitStatus={Object.keys(gitStatusMap).length > 0 ? gitStatusMap : undefined} + onAddToGitIgnore={isInstalled('git') ? handleAddToGitIgnore : undefined} /> )} diff --git a/app/frontend/src/fullscreen/fullscreen.scss b/app/frontend/src/fullscreen/fullscreen.scss index 5bd30119..e48c4fe6 100644 --- a/app/frontend/src/fullscreen/fullscreen.scss +++ b/app/frontend/src/fullscreen/fullscreen.scss @@ -115,24 +115,18 @@ .fe-header { display: flex; align-items: center; - gap: 5px; - padding: 4px 8px 4px 10px; - font-size: 10.5px; + padding: 0 8px 0 14px; + font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ide-text-lo); border-bottom: 1px solid var(--ide-border); flex-shrink: 0; - height: 26px; + height: 28px; cursor: default; overflow: hidden; -} -.fe-header__icon { - display: flex; - align-items: center; - flex-shrink: 0; - filter: saturate(0.2) opacity(0.5); + user-select: none; } .fe-header__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -140,11 +134,11 @@ flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 3px 0 8px; + padding: 2px 0 8px; } .fe-tree::-webkit-scrollbar { width: 4px; } .fe-tree::-webkit-scrollbar-track { background: transparent; } -.fe-tree::-webkit-scrollbar-thumb { background: var(--ide-border-lo); border-radius: 2px; opacity: 0.5; } +.fe-tree::-webkit-scrollbar-thumb { background: var(--ide-border-lo); border-radius: 2px; } .fe-tree::-webkit-scrollbar-thumb:hover { background: var(--ide-text-lo); } .fe-empty { @@ -157,26 +151,23 @@ .fe-node { display: flex; align-items: center; - gap: 2px; - height: 20px; - font-size: 12px; - color: var(--ide-text-mid); + gap: 4px; + height: 22px; + font-size: 13px; + font-weight: 400; + color: var(--ide-text-hi); cursor: pointer; - padding-right: 10px; + padding-right: 8px; white-space: nowrap; overflow: hidden; position: relative; } .fe-node:hover { - background: var(--ide-bg-hi); - color: var(--ide-fg); + background: color-mix(in srgb, var(--ide-fg) 7%, transparent); } .fe-node--selected { - background: var(--ide-select) !important; - color: #fff; -} -.fe-node--selected .fe-node__icon { - filter: saturate(0.9) opacity(0.95); + background: color-mix(in srgb, var(--ide-select) 35%, transparent) !important; + color: var(--ide-fg); } .fe-node--dragover { outline: 1px solid var(--ide-accent); @@ -184,38 +175,29 @@ } .fe-node__chevron { - width: 12px; - text-align: center; - font-size: 8px; - color: var(--ide-text-lo); + width: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; + color: var(--ide-text-lo); opacity: 0.5; } -.fe-node:hover .fe-node__chevron { opacity: 0.85; } +.fe-node:hover .fe-node__chevron, +.fe-node--selected .fe-node__chevron { opacity: 0.9; } .fe-node__icon { flex-shrink: 0; display: flex; align-items: center; - /* Tone down colorful icons in default view — keep source colors for future theme use */ - filter: saturate(0.3) opacity(0.7); - transition: filter 0.1s; -} -.fe-node:hover .fe-node__icon { - filter: saturate(0.55) opacity(0.85); -} -.fe-node--selected .fe-node__icon { - filter: saturate(0.8) opacity(0.9) brightness(1.15); + /* Color data lives in FileIcon.tsx — all palettes preserved for future themes */ } .fe-node__name { overflow: hidden; text-overflow: ellipsis; flex: 1; - padding-left: 2px; + padding-left: 1px; } .fe-node__rename { flex: 1; @@ -223,14 +205,47 @@ border: 1px solid var(--ide-accent); border-radius: 2px; color: var(--ide-fg); - font-size: 12px; + font-size: 13px; padding: 0 4px; outline: none; - height: 17px; + height: 18px; margin-left: 2px; font-family: inherit; } +/* ── Git status badges ────────────────────────────────────────────────────────── */ +.fe-node--git-ignored { opacity: 0.4; } + +.fe-git-badge { + flex-shrink: 0; + font-size: 9.5px; + font-weight: 700; + font-family: 'Consolas', 'Cascadia Code', monospace; + min-width: 14px; + height: 14px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + margin-left: 4px; + letter-spacing: 0.01em; +} +.fe-git-badge--modified { color: #e6a817; } +.fe-git-badge--added { color: #78bd65; } +.fe-git-badge--untracked { color: #78bd65; } +.fe-git-badge--deleted { color: #ff6c6b; } +.fe-git-badge--submodule { color: #51afef; } +.fe-git-badge--dirty { + width: 7px; + height: 7px; + min-width: 7px; + border-radius: 50%; + background: #e6a817; + padding: 0; + margin-left: 6px; +} + /* ── Context menu ─────────────────────────────────────────────────────────────── */ .ctx-menu { background: var(--ide-bg-alt); diff --git a/app/frontend/src/main.tsx b/app/frontend/src/main.tsx index d6769ad6..fdd289ee 100644 --- a/app/frontend/src/main.tsx +++ b/app/frontend/src/main.tsx @@ -11,6 +11,11 @@ import App from './App' import './App.scss' import '../../themes/index.scss' +// Expose React globally so plugin IIFE bundles can reference the host's React +// instance rather than bundling their own. This prevents "Invalid hook call" +// errors that occur when two separate React copies are in the same page. +;(window as any).React = React + // Configure Monaco to use local workers (no CDN — required for offline Wails app) self.MonacoEnvironment = { getWorker(_: unknown, label: string) { diff --git a/app/frontend/src/plugins/claude/claude.scss b/app/frontend/src/plugins/claude/claude.scss deleted file mode 100644 index b9151118..00000000 --- a/app/frontend/src/plugins/claude/claude.scss +++ /dev/null @@ -1,217 +0,0 @@ -.claude { - display: flex; - flex-direction: column; - height: 100%; - background: var(--app-bg); - color: var(--info-bar-hover-color); - font-family: var(--font-ui); - font-size: 13px; -} - -.claude--setup { - align-items: center; - justify-content: center; -} - -.claude__setup-card { - display: flex; - flex-direction: column; - gap: 12px; - width: 320px; - background: var(--surface-raised); - border: 1px solid var(--sep); - border-radius: var(--r-lg); - padding: 24px; -} - -.claude__setup-title { font-size: 15px; font-weight: 600; color: #fff; } -.claude__setup-desc { font-size: 12px; color: var(--info-bar-color); line-height: 1.5; } - -.claude__key-input { - background: var(--surface-raised); - border: 1px solid var(--sep); - border-radius: var(--r-sm); - color: var(--info-bar-hover-color); - padding: 8px 12px; - font-size: 12px; - outline: none; - font-family: var(--font-mono); - transition: border-color var(--t-fast); -} -.claude__key-input:focus { border-color: var(--accent-border); } - -.claude__save-key { - background: var(--accent-dim); - border: 1px solid var(--accent-border); - border-radius: var(--r-sm); - color: var(--accent-hover); - padding: 8px; - font-size: 12px; - font-family: var(--font-ui); - cursor: pointer; - transition: background var(--t-fast); -} -.claude__save-key:hover { background: rgba(10,132,255,0.25); } - -/* ── Header ──────────────────────────────────────────────────────────────── */ -.claude__header { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.claude__title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; } - -.claude__key-btn, .claude__clear { - background: var(--surface-raised); - border: 1px solid var(--sep); - border-radius: var(--r-xs); - color: var(--info-bar-color); - padding: 3px 10px; - font-size: 11px; - font-family: var(--font-ui); - cursor: pointer; - transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); -} -.claude__key-btn:hover, .claude__clear:hover { - color: var(--info-bar-hover-color); - background: var(--surface-overlay); - border-color: var(--sep-strong); -} - -/* ── Messages ────────────────────────────────────────────────────────────── */ -.claude__messages { - flex: 1; - overflow-y: auto; - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - scrollbar-width: thin; - scrollbar-color: var(--sep-strong) transparent; -} - -.claude__welcome { - color: var(--info-bar-color); - font-size: 12px; - text-align: center; - padding: 20px; -} - -.claude__msg { display: flex; } -.claude__msg--user { justify-content: flex-end; } -.claude__msg--assistant { justify-content: flex-start; } - -.claude__msg-content { - max-width: 80%; - padding: 9px 13px; - border-radius: var(--r-md); - font-size: 12.5px; - line-height: 1.6; - white-space: pre-wrap; -} -.claude__msg--user .claude__msg-content { - background: var(--accent-dim); - border: 1px solid var(--accent-border); - color: #d6e8ff; -} -.claude__msg--assistant .claude__msg-content { - background: var(--surface-raised); - border: 1px solid var(--sep); - color: var(--info-bar-hover-color); -} - -/* ── Code blocks ─────────────────────────────────────────────────────────── */ -.claude-code { - background: var(--info-bar-bg); - border: 1px solid var(--sep); - border-radius: var(--r-sm); - margin: 6px 0; - overflow: hidden; -} -.claude-code__pre { - margin: 0; - padding: 10px; - font-size: 11px; - overflow-x: auto; - font-family: var(--font-mono); - color: var(--info-bar-hover-color); -} -.claude-code__run { - display: block; - width: 100%; - background: var(--accent-dim); - border: none; - border-top: 1px solid var(--sep); - color: var(--accent-hover); - padding: 5px; - font-size: 11px; - font-family: var(--font-ui); - cursor: pointer; - text-align: center; - transition: background var(--t-fast); -} -.claude-code__run:hover { background: rgba(10,132,255,0.25); } - -/* ── Typing indicator ────────────────────────────────────────────────────── */ -.claude__typing { - display: flex; - gap: 4px; - padding: 12px; -} -.claude__typing span { - width: 6px; height: 6px; - background: var(--info-bar-color); - border-radius: 50%; - animation: claudeDot 1.2s infinite ease-in-out; -} -.claude__typing span:nth-child(2) { animation-delay: 0.2s; } -.claude__typing span:nth-child(3) { animation-delay: 0.4s; } -@keyframes claudeDot { - 0%, 80%, 100% { transform: scale(0.8); opacity: 0.4; } - 40% { transform: scale(1.1); opacity: 1; } -} - -/* ── Input row ───────────────────────────────────────────────────────────── */ -.claude__input-row { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 10px 14px; - border-top: 1px solid var(--border-color); - flex-shrink: 0; -} - -.claude__input { - flex: 1; - background: var(--surface-raised); - border: 1px solid var(--sep); - border-radius: var(--r-md); - color: var(--info-bar-hover-color); - padding: 8px 12px; - font-size: 12.5px; - font-family: var(--font-ui); - resize: none; - outline: none; - line-height: 1.5; - transition: border-color var(--t-fast); -} -.claude__input::placeholder { color: var(--info-bar-color); opacity: 0.6; } -.claude__input:focus { border-color: var(--accent-border); } - -.claude__send { - background: var(--accent-dim); - border: 1px solid var(--accent-border); - border-radius: var(--r-md); - color: var(--accent-hover); - width: 36px; height: 36px; - font-size: 16px; - cursor: pointer; - flex-shrink: 0; - transition: background var(--t-fast); -} -.claude__send:hover:not(:disabled) { background: rgba(10,132,255,0.25); } -.claude__send:disabled { opacity: 0.35; cursor: default; } diff --git a/app/frontend/src/plugins/claude/index.tsx b/app/frontend/src/plugins/claude/index.tsx deleted file mode 100644 index f5c55598..00000000 --- a/app/frontend/src/plugins/claude/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import type { Plugin, PluginTabProps } from '@cmdide/plugin-sdk' -import './claude.scss' - -interface Message { - role: 'user' | 'assistant' - content: string -} - -function renderMessage(text: string, onRun?: (code: string) => void): React.ReactNode { - const parts: React.ReactNode[] = [] - let last = 0 - let partKey = 0 - const matches = [...text.matchAll(/```(\w*)\n?([\s\S]*?)```/g)] - if (matches.length === 0) return text - - for (const m of matches) { - if (m.index! > last) parts.push({text.slice(last, m.index)}) - const code = m[2].trim() - parts.push( -
-
{code}
- {onRun && ( - - )} -
- ) - last = m.index! + m[0].length - } - if (last < text.length) parts.push({text.slice(last)}) - return parts -} - -function ClaudeTab({ context }: PluginTabProps) { - const { executeCommand } = context - const [apiKey, setApiKey] = useState(() => { - try { return localStorage.getItem('claude:api-key') ?? '' } catch { return '' } - }) - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [loading, setLoading] = useState(false) - const [showKey, setShowKey] = useState(!apiKey) - const bottomRef = useRef(null) - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, loading]) - - const saveKey = (k: string) => { - setApiKey(k) - try { localStorage.setItem('claude:api-key', k) } catch {} - setShowKey(false) - } - - const send = async () => { - const text = input.trim() - if (!text || !apiKey || loading) return - setInput('') - const newMessages: Message[] = [...messages, { role: 'user', content: text }] - setMessages(newMessages) - setLoading(true) - - try { - const res = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: 'claude-haiku-4-5-20251001', - max_tokens: 1024, - messages: newMessages.map(m => ({ role: m.role, content: m.content })), - }), - }) - if (!res.ok) { - let errorDetail = '' - try { - const errorBody = await res.json() - errorDetail = errorBody?.error?.message ?? errorBody?.message ?? '' - } catch { - try { - errorDetail = await res.text() - } catch { - errorDetail = '' - } - } - throw new Error(`API error ${res.status}${errorDetail ? `: ${errorDetail}` : ''}`) - } - const data = await res.json() - const reply = data.content?.[0]?.text ?? '' - setMessages([...newMessages, { role: 'assistant', content: reply }]) - } catch (e: any) { - setMessages([...newMessages, { role: 'assistant', content: `Error: ${e.message}` }]) - } finally { - setLoading(false) - } - } - - const handleRun = (code: string) => { - if (executeCommand) executeCommand(code) - } - - if (showKey) { - return ( -
-
-
Claude API Key
-
Enter your Anthropic API key to enable Claude chat.
- { if (e.key === 'Enter') saveKey((e.target as HTMLInputElement).value) }} - /> - -
-
- ) - } - - return ( -
-
- Claude - - {messages.length > 0 && ( - - )} -
-
- {messages.length === 0 && ( -
Ask Claude anything. Code suggestions can run directly in your terminal.
- )} - {messages.map((m, i) => ( -
-
- {m.role === 'assistant' - ? renderMessage(m.content, executeCommand ? handleRun : undefined) - : m.content} -
-
- ))} - {loading && ( -
-
-
- )} -
-
-
-