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
98 changes: 98 additions & 0 deletions src/browser/components/Settings/sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,45 @@ import {
SelectTrigger,
SelectValue,
} from "@/browser/components/ui/select";
import { Input } from "@/browser/components/ui/input";
import { Checkbox } from "@/browser/components/ui/checkbox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import {
EDITOR_CONFIG_KEY,
DEFAULT_EDITOR_CONFIG,
type EditorConfig,
type EditorType,
} from "@/common/constants/storage";

const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [
{ value: "vscode", label: "VS Code" },
{ value: "cursor", label: "Cursor" },
{ value: "zed", label: "Zed" },
{ value: "custom", label: "Custom" },
];

export function GeneralSection() {
const { theme, setTheme } = useTheme();
const [editorConfig, setEditorConfig] = usePersistedState<EditorConfig>(
EDITOR_CONFIG_KEY,
DEFAULT_EDITOR_CONFIG
);

const handleEditorChange = (editor: EditorType) => {
setEditorConfig((prev) => ({ ...prev, editor }));
};

const handleCustomCommandChange = (customCommand: string) => {
setEditorConfig((prev) => ({ ...prev, customCommand }));
};

const handleRemoteExtensionChange = (useRemoteExtension: boolean) => {
setEditorConfig((prev) => ({ ...prev, useRemoteExtension }));
};

// Remote-SSH is only supported for VS Code and Cursor
const supportsRemote = editorConfig.editor === "vscode" || editorConfig.editor === "cursor";

return (
<div className="space-y-6">
Expand All @@ -34,6 +70,68 @@ export function GeneralSection() {
</Select>
</div>
</div>

<div>
<h3 className="text-foreground mb-4 text-sm font-medium">Editor</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Default Editor</div>
<div className="text-muted text-xs">Editor to open workspaces in</div>
</div>
<Select value={editorConfig.editor} onValueChange={handleEditorChange}>
<SelectTrigger className="border-border-medium bg-background-secondary hover:bg-hover h-9 w-auto cursor-pointer rounded-md border px-3 text-sm transition-colors">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EDITOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{editorConfig.editor === "custom" && (
<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Custom Command</div>
<div className="text-muted text-xs">Command to run (path will be appended)</div>
</div>
<Input
value={editorConfig.customCommand ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleCustomCommandChange(e.target.value)
}
placeholder="e.g., nvim"
className="border-border-medium bg-background-secondary h-9 w-40"
/>
</div>
)}

{supportsRemote && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-foreground text-sm">Use Remote-SSH for SSH workspaces</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted cursor-help text-xs">(?)</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
When enabled, opens SSH workspaces directly in the editor using the Remote-SSH
extension. Requires the Remote-SSH extension to be installed.
</TooltipContent>
</Tooltip>
</div>
<Checkbox
checked={editorConfig.useRemoteExtension}
onCheckedChange={handleRemoteExtensionChange}
/>
</div>
)}
</div>
</div>
</div>
);
}
71 changes: 52 additions & 19 deletions src/browser/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import { GitStatusIndicator } from "./GitStatusIndicator";
import { RuntimeBadge } from "./RuntimeBadge";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
Expand All @@ -9,6 +10,7 @@ import { Button } from "@/browser/components/ui/button";
import type { RuntimeConfig } from "@/common/types/runtime";
import { useTutorial } from "@/browser/contexts/TutorialContext";
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";

interface WorkspaceHeaderProps {
workspaceId: string;
Expand All @@ -26,13 +28,26 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
runtimeConfig,
}) => {
const openTerminal = useOpenTerminal();
const openInEditor = useOpenInEditor();
const gitStatus = useGitStatus(workspaceId);
const { canInterrupt } = useWorkspaceSidebarState(workspaceId);
const { startSequence: startTutorial, isSequenceCompleted } = useTutorial();
const [editorError, setEditorError] = useState<string | null>(null);

const handleOpenTerminal = useCallback(() => {
openTerminal(workspaceId, runtimeConfig);
}, [workspaceId, openTerminal, runtimeConfig]);

const handleOpenInEditor = useCallback(async () => {
setEditorError(null);
const result = await openInEditor(workspaceId, runtimeConfig);
if (!result.success && result.error) {
setEditorError(result.error);
// Clear error after 3 seconds
setTimeout(() => setEditorError(null), 3000);
}
}, [workspaceId, openInEditor, runtimeConfig]);

// Start workspace tutorial on first entry (only if settings tutorial is done)
useEffect(() => {
// Don't show workspace tutorial until settings tutorial is completed
Expand Down Expand Up @@ -64,24 +79,42 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
{namedWorkspacePath}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenTerminal}
className="text-muted hover:text-foreground ml-2 h-6 w-6 shrink-0 [&_svg]:h-4 [&_svg]:w-4"
data-tutorial="terminal-button"
>
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
</svg>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
</TooltipContent>
</Tooltip>
<div className="flex items-center">
{editorError && <span className="text-danger-soft mr-2 text-xs">{editorError}</span>}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => void handleOpenInEditor()}
className="text-muted hover:text-foreground h-6 w-6 shrink-0"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Open in editor ({formatKeybind(KEYBINDS.OPEN_IN_EDITOR)})
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenTerminal}
className="text-muted hover:text-foreground ml-1 h-6 w-6 shrink-0 [&_svg]:h-4 [&_svg]:w-4"
data-tutorial="terminal-button"
>
<svg viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
</svg>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
23 changes: 23 additions & 0 deletions src/browser/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/common/lib/utils";

export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"border-input placeholder:text-muted focus-visible:ring-ring flex h-10 w-full rounded-md border bg-transparent px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";

export { Input };
2 changes: 1 addition & 1 deletion src/browser/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-dark border-border text-foreground relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"bg-dark border-border text-foreground relative z-[1600] max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
Expand Down
86 changes: 86 additions & 0 deletions src/browser/hooks/useOpenInEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useCallback } from "react";
import { useAPI } from "@/browser/contexts/API";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import {
EDITOR_CONFIG_KEY,
DEFAULT_EDITOR_CONFIG,
type EditorConfig,
} from "@/common/constants/storage";
import type { RuntimeConfig } from "@/common/types/runtime";
import { isSSHRuntime } from "@/common/types/runtime";

export interface OpenInEditorResult {
success: boolean;
error?: string;
}

/**
* Hook to open a workspace in the user's configured code editor.
*
* If no editor is configured, opens Settings to the General section.
* For SSH workspaces with unsupported editors (Zed, custom), returns an error.
*
* @returns A function that takes workspaceId and optional runtimeConfig,
* returns a result object with success/error status.
*/
export function useOpenInEditor() {
const { api } = useAPI();
const { open: openSettings } = useSettings();

return useCallback(
async (workspaceId: string, runtimeConfig?: RuntimeConfig): Promise<OpenInEditorResult> => {
// Read editor config from localStorage
const editorConfig = readPersistedState<EditorConfig>(
EDITOR_CONFIG_KEY,
DEFAULT_EDITOR_CONFIG
);

const isSSH = isSSHRuntime(runtimeConfig);

// For custom editor with no command configured, open settings
if (editorConfig.editor === "custom" && !editorConfig.customCommand) {
openSettings("general");
return { success: false, error: "Please configure a custom editor command in Settings" };
}

// For SSH workspaces, validate the editor supports remote
if (isSSH && editorConfig.useRemoteExtension) {
if (editorConfig.editor === "zed") {
return { success: false, error: "Zed does not support Remote-SSH for SSH workspaces" };
}
if (editorConfig.editor === "custom") {
return {
success: false,
error: "Custom editors do not support Remote-SSH for SSH workspaces",
};
}
}

// For SSH workspaces without remote extension, we can't open
if (isSSH && !editorConfig.useRemoteExtension) {
return {
success: false,
error: "Enable 'Use Remote-SSH' in Settings to open SSH workspaces in editor",
};
}

// Call the backend API
const result = await api?.general.openWorkspaceInEditor({
workspaceId,
editorConfig,
});

if (!result) {
return { success: false, error: "API not available" };
}

if (!result.success) {
return { success: false, error: result.error };
}

return { success: true };
},
[api, openSettings]
);
}
4 changes: 4 additions & 0 deletions src/browser/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ export const KEYBINDS = {
// macOS: Cmd+T, Win/Linux: Ctrl+T
OPEN_TERMINAL: { key: "T", ctrl: true },

/** Open workspace in editor */
// macOS: Cmd+Shift+E, Win/Linux: Ctrl+Shift+E
OPEN_IN_EDITOR: { key: "E", ctrl: true, shift: true },

/** Open Command Palette */
// VS Code-style palette
// macOS: Cmd+Shift+P, Win/Linux: Ctrl+Shift+P
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
workspaceService: services.workspaceService,
providerService: services.providerService,
terminalService: services.terminalService,
editorService: services.editorService,
windowService: services.windowService,
updateService: services.updateService,
tokenizerService: services.tokenizerService,
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async function createTestServer(): Promise<TestServerHandle> {
workspaceService: services.workspaceService,
providerService: services.providerService,
terminalService: services.terminalService,
editorService: services.editorService,
windowService: services.windowService,
updateService: services.updateService,
tokenizerService: services.tokenizerService,
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const mockWindow: BrowserWindow = {
workspaceService: serviceContainer.workspaceService,
providerService: serviceContainer.providerService,
terminalService: serviceContainer.terminalService,
editorService: serviceContainer.editorService,
windowService: serviceContainer.windowService,
updateService: serviceContainer.updateService,
tokenizerService: serviceContainer.tokenizerService,
Expand Down
19 changes: 19 additions & 0 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ export const PREFERRED_COMPACTION_MODEL_KEY = "preferredCompactionModel";
*/
export const VIM_ENABLED_KEY = "vimEnabled";

/**
* Editor configuration for "Open in Editor" feature (global)
* Format: "editorConfig"
*/
export const EDITOR_CONFIG_KEY = "editorConfig";

export type EditorType = "vscode" | "cursor" | "zed" | "custom";

export interface EditorConfig {
editor: EditorType;
customCommand?: string; // Only when editor='custom'
useRemoteExtension: boolean; // For SSH workspaces, use Remote-SSH
}

export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
editor: "vscode",
useRemoteExtension: true,
};

/**
* Tutorial state storage key (global)
* Stores: { disabled: boolean, completed: { settings?: true, creation?: true, workspace?: true } }
Expand Down
Loading