Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/security-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions app/frontend/src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
85 changes: 77 additions & 8 deletions app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
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 (
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
background: 'var(--app-bg)',
}}>
<div style={{
maxWidth: 720,
width: '100%',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 18,
padding: 20,
background: 'rgba(255,255,255,0.03)',
color: 'var(--tab-color)',
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace",
}}>
<div style={{ fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', opacity: 0.6, marginBottom: 8 }}>
Plugin Error
</div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 10 }}>
{this.props.pluginName} failed to render
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, opacity: 0.82, whiteSpace: 'pre-wrap' }}>
{this.state.error.message}
</div>
</div>
</div>
)
}

return this.props.children
}
}

export default function App() {
const [state, dispatch] = useReducer(tabReducer, initialState)
const { tabs, activeId } = state
Expand All @@ -252,17 +311,22 @@ export default function App() {
const [terminalCwds, setTerminalCwds] = useState<Record<string, string>>({})
// tabType → Plugin; rebuilt whenever a plugin is installed/uninstalled
const [plugins, setPlugins] = useState<Record<string, Plugin>>({})
const [pluginCommands, setPluginCommands] = useState<Record<string, InstalledPluginCommand>>({})

const contentRef = useRef<HTMLDivElement>(null)
const dragging = useRef(false)

// ── plugin loader ─────────────────────────────────────────────────────────────
const reloadPlugins = useCallback(async () => {
if (!__PLUGINS__) return
bootstrapBuiltins()
const loaded = await loadInstalledPlugins().catch(() => [] as Plugin[])
const map: Record<string, Plugin> = {}
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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }))}
/>
)
Expand Down Expand Up @@ -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) {
Expand All @@ -757,7 +822,11 @@ export default function App() {
: undefined,
openFile: (path: string) => handleOpenFileAtLine(path, 0, 0),
}
return <plugin.TabComponent tabId={tab.id} active={isActive} context={context} />
return (
<PluginErrorBoundary pluginName={plugin.name}>
<plugin.TabComponent tabId={tab.id} active={isActive} context={context} />
</PluginErrorBoundary>
)
}
return null
}
Expand Down
Loading
Loading