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
6 changes: 6 additions & 0 deletions apps/desktop/src/lib/trpc/routers/external/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export const createExternalRouter = () => {
await shell.openExternal(input);
}),

openInFinder: publicProcedure
.input(z.string())
.mutation(async ({ input }) => {
shell.showItemInFolder(input);
}),

openFileInEditor: publicProcedure
.input(
z.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";

export function assignRandomColor(): string {
return PROJECT_COLOR_VALUES[Math.floor(Math.random() * PROJECT_COLOR_VALUES.length)];
return PROJECT_COLOR_VALUES[
Math.floor(Math.random() * PROJECT_COLOR_VALUES.length)
];
}
25 changes: 25 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,28 @@ export async function getGitRoot(path: string): Promise<string> {
throw new Error(`Not a git repository: ${path}`);
}
}

/**
* Checks if a worktree exists in git's worktree list
* @param mainRepoPath - Path to the main repository
* @param worktreePath - Path to the worktree to check
* @returns true if the worktree exists in git, false otherwise
*/
export async function worktreeExists(
mainRepoPath: string,
worktreePath: string,
): Promise<boolean> {
try {
const git = simpleGit(mainRepoPath);
const worktrees = await git.raw(["worktree", "list", "--porcelain"]);

// Parse porcelain format to verify worktree exists
// Format: "worktree /path/to/worktree" followed by HEAD, branch, etc.
const lines = worktrees.split("\n");
const worktreePrefix = `worktree ${worktreePath}`;
return lines.some((line) => line.trim() === worktreePrefix);
} catch (error) {
console.error(`Failed to check worktree existence: ${error}`);
throw error;
}
}
35 changes: 17 additions & 18 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { join } from "node:path";
import { db } from "main/lib/db";
import { nanoid } from "nanoid";
import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
createWorktree,
generateBranchName,
removeWorktree,
worktreeExists,
} from "./utils/git";
import { copySetupFiles, loadSetupConfig } from "./utils/setup";

Expand Down Expand Up @@ -257,23 +257,12 @@ export const createWorkspacesRouter = () => {

if (worktree && project) {
try {
const gitInstance = simpleGit(project.mainRepoPath);
const worktrees = await gitInstance.raw([
"worktree",
"list",
"--porcelain",
]);

// Parse porcelain format to verify worktree exists in git before deletion
// (porcelain format: "worktree /path/to/worktree" followed by HEAD, branch, etc.)
const lines = worktrees.split("\n");
const worktreePrefix = `worktree ${worktree.path}`;
const worktreeExists = lines.some(
(line) => line.trim() === worktreePrefix,
const exists = await worktreeExists(
project.mainRepoPath,
worktree.path,
);

if (!worktreeExists) {
// Worktree doesn't exist in git, but we can still delete the workspace
if (!exists) {
return {
canDelete: true,
reason: null,
Expand Down Expand Up @@ -324,9 +313,19 @@ export const createWorkspacesRouter = () => {

if (worktree && project) {
try {
await removeWorktree(project.mainRepoPath, worktree.path);
const exists = await worktreeExists(
project.mainRepoPath,
worktree.path,
);

if (exists) {
await removeWorktree(project.mainRepoPath, worktree.path);
} else {
console.warn(
`Worktree ${worktree.path} not found in git, skipping removal`,
);
}
} catch (error) {
// If worktree removal fails, return error and don't proceed with DB cleanup
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error("Failed to remove worktree:", errorMessage);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export class TerminalManager extends EventEmitter {

// Spawn as login shell (-l for zsh/bash) to source profile files
// This ensures pyenv, nvm, etc. are initialized before .zshrc runs
const shellArgs = shell.includes("zsh") || shell.includes("bash") ? ["-l"] : [];
const shellArgs =
shell.includes("zsh") || shell.includes("bash") ? ["-l"] : [];

const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,124 +1,53 @@
import { useMemo } from "react";
import { trpc } from "renderer/lib/trpc";
import type { Tab } from "renderer/stores";
import { TabType, useActiveTabIds, useTabs } from "renderer/stores";
import { DropOverlay } from "./DropOverlay";
import { EmptyTabView } from "./EmptyTabView";
import { GroupTabView } from "./GroupTabView";
import { SetupTabView } from "./SetupTabView";
import { SingleTabView } from "./SingleTabView";
import { useTabContentDrop } from "./useTabContentDrop";

interface RenderTabContentProps {
tab: Tab;
activeTabId: string | null;
isDropZone: boolean;
}

function renderTabContent({
tab,
activeTabId,
isDropZone,
}: RenderTabContentProps) {
const isActive = tab.id === activeTabId;
const content = (() => {
switch (tab.type) {
case TabType.Setup:
return <SetupTabView tab={tab} />;
case TabType.Single:
return <SingleTabView tab={tab} isDropZone={isActive && isDropZone} />;
case TabType.Group:
return <GroupTabView tab={tab} />;
default:
return null;
}
})();

const style: React.CSSProperties = {
visibility: isActive ? "visible" : "hidden",
pointerEvents: isActive ? "auto" : "none",
};

return (
<div className="w-full h-full absolute inset-0" style={style}>
{content}
</div>
);
}
import { SetupTabView } from "./SetupTabView";

export function TabsContent() {
const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
const activeWorkspaceId = activeWorkspace?.id;
const allTabs = useTabs();
const activeTabIds = useActiveTabIds();

const { tabToRender, allTabs: renderedTabs } = useMemo(() => {
if (!activeWorkspaceId) return { tabToRender: null, allTabs: [] };
const tabToRender = useMemo(() => {
if (!activeWorkspaceId) return null;
const activeTabId = activeTabIds[activeWorkspaceId];

// Get all top-level tabs (tabs without parent)
const topLevelTabs = allTabs.filter((tab) => !tab.parentId);

if (!activeTabId) {
return { tabToRender: null, allTabs: topLevelTabs };
}
if (!activeTabId) return null;

const activeTab = allTabs.find((tab) => tab.id === activeTabId);
if (!activeTab) {
return { tabToRender: null, allTabs: topLevelTabs };
}
if (!activeTab) return null;

let displayTab = activeTab;
if (activeTab.parentId) {
const parentGroup = allTabs.find((tab) => tab.id === activeTab.parentId);
displayTab = parentGroup || activeTab;
return parentGroup || null;
}

return { tabToRender: displayTab, allTabs: topLevelTabs };
return activeTab;
}, [activeWorkspaceId, activeTabIds, allTabs]);

const { isDropZone, attachDrop } = useTabContentDrop(tabToRender);

const activeTabId = tabToRender?.id ?? null;

if (!tabToRender) {
return (
<div ref={attachDrop} className="flex-1 h-full relative">
<div ref={attachDrop} className="flex-1 h-full">
<EmptyTabView />
{renderedTabs.map((tab) => {
return (
<div key={tab.id}>
{renderTabContent({
tab,
activeTabId: null,
isDropZone: false,
})}
</div>
);
})}
</div>
);
}

const dropOverlayMessage =
tabToRender.type === TabType.Single
? "Drop to create split view"
: "Drop to add to split view";

return (
<div ref={attachDrop} className="flex-1 h-full relative">
{renderedTabs.map((tab) => {
return (
<div key={tab.id}>
{renderTabContent({
tab,
activeTabId,
isDropZone,
})}
</div>
);
})}
{isDropZone && <DropOverlay message={dropOverlayMessage} />}
{tabToRender.type === TabType.Setup && <SetupTabView tab={tabToRender} />}
{tabToRender.type === TabType.Single && (
<SingleTabView tab={tabToRender} isDropZone={isDropZone} />
)}
{tabToRender.type === TabType.Group && <GroupTabView tab={tabToRender} />}
{isDropZone && <DropOverlay message="Drop to create split view" />}
</div>
);
}
2 changes: 1 addition & 1 deletion apps/desktop/src/shared/constants/project-colors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const PROJECT_COLORS = [
export const PROJECT_COLORS: { name: string; value: string }[] = [
{ name: "Blue", value: "#3b82f6" },
{ name: "Green", value: "#22c55e" },
{ name: "Yellow", value: "#eab308" },
Expand Down
Loading