From f97df37400796d7b9bf749f1323b204eafd430e4 Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Mon, 25 May 2026 19:16:48 -0400 Subject: [PATCH 1/7] added Git Insights to Plugin Manager, improved /problems UI, fxixed in app zoom issues, etc. --- .gitmodules | 3 + app/frontend/src/App.scss | 5 + app/frontend/src/App.tsx | 84 +++++- app/frontend/src/components/Problems.scss | 259 +++++++++++++++--- app/frontend/src/components/Problems.tsx | 247 ++++++++++++----- app/frontend/src/components/Terminal.tsx | 61 ++--- app/frontend/src/components/ZoomIndicator.tsx | 19 +- app/frontend/src/fullscreen/FileExplorer.tsx | 47 +++- app/frontend/src/fullscreen/FileIcon.tsx | 63 +++-- app/frontend/src/fullscreen/FullscreenIDE.tsx | 71 ++++- app/frontend/src/fullscreen/fullscreen.scss | 97 ++++--- app/frontend/src/plugins/claude/claude.scss | 217 --------------- app/frontend/src/plugins/claude/index.tsx | 186 ------------- app/frontend/src/plugins/directory.ts | 12 + app/frontend/src/plugins/index.ts | 56 +++- app/frontend/src/types/index.ts | 2 +- app/frontend/wailsjs/go/main/App.d.ts | 5 + app/frontend/wailsjs/go/main/App.js | 8 + app/frontend/wailsjs/go/models.ts | 23 ++ app/git.go | 107 ++++++++ app/utils.go | 6 +- note.txt | 10 + packages/ai-plugin | 1 + packages/git | 2 +- 24 files changed, 944 insertions(+), 647 deletions(-) delete mode 100644 app/frontend/src/plugins/claude/claude.scss delete mode 100644 app/frontend/src/plugins/claude/index.tsx create mode 100644 app/git.go create mode 100644 note.txt create mode 160000 packages/ai-plugin 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/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..94e781c8 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 } 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) @@ -261,8 +321,11 @@ export default function App() { if (!__PLUGINS__) return 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 +521,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 +726,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 +807,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 +821,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..4c93b49d 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, @@ -27,6 +27,7 @@ interface Props { xtermTheme: ITheme initialCwd?: string defaultZoom?: number + pluginCommands?: Record onCwdChange?: (cwd: string) => void } @@ -66,33 +67,28 @@ 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) @@ -110,6 +106,8 @@ export default function Terminal({ tabId, active, xtermTheme, initialCwd, defaul 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) @@ -361,7 +359,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() @@ -571,21 +569,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, '') 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..59afe9f5 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -1,13 +1,49 @@ 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, ExplorerGitStatus, ExplorerGitIgnorePath } 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' +interface GitStatusResult { + isGitRepo: boolean + root: string + files: Record | null + submodules: string[] | null +} + +// Builds an absolute-path → status-code map from a git status result. +// Propagates 'dirty' upward into parent directories for changed files. +function buildGitStatusMap(gs: GitStatusResult): Record { + const map: Record = {} + const root = gs.root + + for (const sub of gs.submodules ?? []) { + map[`${root}/${sub}`] = 'submodule' + } + + for (const [rel, code] of Object.entries(gs.files ?? {})) { + const abs = `${root}/${rel}` + if (!map[abs]) map[abs] = code + + 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 +} + function langFromExt(ext: string): string { const map: Record = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', @@ -41,7 +77,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 +170,19 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW // ── tree loading ────────────────────────────────────────────────────────────── const loadTree = useCallback(async () => { if (!cwd) return - setTree(await ExplorerOpen(cwd) as FileNode) + const [treeNode] = await Promise.all([ + ExplorerOpen(cwd), + (async () => { + if (!isInstalled('git')) { setGitStatusMap({}); return } + try { + const gs = await ExplorerGitStatus(cwd) + setGitStatusMap(gs.isGitRepo ? buildGitStatusMap(gs) : {}) + } catch { + setGitStatusMap({}) + } + })(), + ]) + setTree(treeNode as FileNode) }, [cwd]) useEffect(() => { loadTree() }, [loadTree]) @@ -481,6 +530,20 @@ 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 + await ExplorerGitIgnorePath(cwd, relPath) + // Refresh git status so ignored indicator appears immediately + try { + const gs = await ExplorerGitStatus(cwd) + setGitStatusMap(gs.isGitRepo ? buildGitStatusMap(gs) : {}) + } catch { /* ignore */ } + }, [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/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 && ( -
-
-
- )} -
-
-
-