Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ function TerminalCell({
}) {
const clearCell = useCommandCenterStore((s) => s.clearCell);
const { getRecentFolders, getFolderDisplayName } = useFolders();
const cwd = terminalCwd ?? getRecentFolders(1)[0]?.path;
const defaultCwd = useSettingsStore((s) => s.terminalDefaultCwd);
// Cells restored from a previous session may predate the setting, so fall
// back to it here too rather than only at creation time.
const cwd = terminalCwd ?? (defaultCwd || getRecentFolders(1)[0]?.path);
const folderName = cwd ? getFolderDisplayName(cwd) : null;
const stateKey = getTerminalCellStateKey(terminalId);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@posthog/ui/features/command-center/hooks/useAvailableTasks", () => ({
useAvailableTasks: () => [],
Expand All @@ -18,28 +20,45 @@ vi.mock("@posthog/ui/features/folders/useFolders", () => ({

import { TaskSelector } from "./TaskSelector";

function renderSelector() {
return render(
// Mirrors how CommandCenterPanel drives the selector: `open` is real state, so
// the popup can actually unmount when a choice closes it.
function ControlledSelector({
onNewTerminal,
}: {
onNewTerminal: (cwd?: string) => void;
}) {
const [open, setOpen] = useState(true);
return (
<Theme>
<TaskSelector
cellIndex={0}
open
onOpenChange={() => {}}
onNewTerminal={() => {}}
open={open}
onOpenChange={setOpen}
onNewTerminal={onNewTerminal}
>
<button type="button">Add task</button>
</TaskSelector>
</Theme>,
</Theme>
);
}

function renderSelector(onNewTerminal: (cwd?: string) => void = () => {}) {
return render(<ControlledSelector onNewTerminal={onNewTerminal} />);
}

function expectPopupWidth(input: HTMLElement) {
expect(input.closest(".combobox-content")).toHaveStyle({
minWidth: "240px",
});
}

describe("TaskSelector", () => {
// Reset before rather than after: an afterEach hook runs ahead of Testing
// Library's auto-cleanup, so it would update a still-mounted component.
beforeEach(() => {
useSettingsStore.setState({ terminalDefaultCwd: "" });
});

it("keeps the task popup wider than its compact trigger", () => {
renderSelector();

Expand All @@ -53,4 +72,26 @@ describe("TaskSelector", () => {

expectPopupWidth(screen.getByPlaceholderText("Search folders..."));
});

it("opens the default directory instead of prompting for a folder", async () => {
useSettingsStore.setState({ terminalDefaultCwd: "/default" });
const onNewTerminal = vi.fn();
renderSelector(onNewTerminal);

await userEvent.click(screen.getByRole("button", { name: "Terminal" }));

expect(onNewTerminal).toHaveBeenCalledWith("/default");
// The popup closes outright, so neither step is left on screen.
expect(screen.queryByPlaceholderText("Search folders...")).toBeNull();
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});

it("still offers an explicit folder choice once a default is set", async () => {
useSettingsStore.setState({ terminalDefaultCwd: "/default" });
renderSelector();

await userEvent.click(screen.getByRole("button", { name: "Terminal in…" }));

expect(screen.getByPlaceholderText("Search folders...")).toBeVisible();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Popover } from "@radix-ui/themes";
import { type ReactNode, useCallback, useState } from "react";
import { Combobox } from "../../../primitives/combobox/Combobox";
import { useFolders } from "../../folders/useFolders";
import { useSettingsStore } from "../../settings/settingsStore";
import { useCommandCenterStore } from "../commandCenterStore";
import { useAvailableTasks } from "../hooks/useAvailableTasks";

Expand Down Expand Up @@ -39,6 +40,7 @@ export function TaskSelector({
const assignTask = useCommandCenterStore((s) => s.assignTask);
const { getRecentFolders } = useFolders();
const folders = getRecentFolders();
const defaultCwd = useSettingsStore((s) => s.terminalDefaultCwd);
const [step, setStep] = useState<"tasks" | "folder">("tasks");

const handleOpenChange = useCallback(
Expand Down Expand Up @@ -70,14 +72,21 @@ export function TaskSelector({
}
}, [handleOpenChange, onNewTask]);

// A configured default wins outright: no folder prompt, even with several
// folders registered. "Terminal in…" below stays as the one-off escape hatch.
const handleNewTerminal = useCallback(() => {
if (defaultCwd) {
handleOpenChange(false);
onNewTerminal?.(defaultCwd);
return;
}
if (folders.length > 1) {
setStep("folder");
return;
}
handleOpenChange(false);
onNewTerminal?.(folders[0]?.path);
}, [folders, handleOpenChange, onNewTerminal]);
}, [defaultCwd, folders, handleOpenChange, onNewTerminal]);

const handleBrainrot = useCallback(() => {
handleOpenChange(false);
Expand Down Expand Up @@ -177,6 +186,16 @@ export function TaskSelector({
Terminal
</button>
)}
{onNewTerminal && defaultCwd && folders.length > 0 && (
<button
type="button"
className="combobox-footer-button"
onClick={() => setStep("folder")}
>
<Folder size={11} weight="bold" />
Terminal in…
</button>
)}
{onBrainrot && (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { ANALYTICS_EVENTS } from "@posthog/shared";
import { X } from "@phosphor-icons/react";
import { useHostTRPCClient } from "@posthog/host-router/react";
import { Button } from "@posthog/quill";
import { ANALYTICS_EVENTS, compactHomePath } from "@posthog/shared";
import { SettingRow } from "@posthog/ui/features/settings/SettingRow";
import {
type TerminalFont,
useSettingsStore,
} from "@posthog/ui/features/settings/settingsStore";
import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce";
import { toast } from "@posthog/ui/primitives/toast";
import { track } from "@posthog/ui/shell/analytics";
import { logger } from "@posthog/ui/shell/logger";
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
import { Flex, Select, Switch, Text, TextField } from "@radix-ui/themes";
import { useEffect, useState } from "react";

const log = logger.scope("terminal-settings");

export function TerminalSettings() {
const terminalFont = useSettingsStore((s) => s.terminalFont);
const setTerminalFont = useSettingsStore((s) => s.setTerminalFont);
Expand All @@ -22,6 +30,14 @@ export function TerminalSettings() {
const setTerminalGpuRendering = useSettingsStore(
(s) => s.setTerminalGpuRendering,
);
const terminalDefaultCwd = useSettingsStore((s) => s.terminalDefaultCwd);
const setTerminalDefaultCwd = useSettingsStore(
(s) => s.setTerminalDefaultCwd,
);

const hostClient = useHostTRPCClient();
const { localWorkspaces } = useHostCapabilities();
const [isChoosingDirectory, setIsChoosingDirectory] = useState(false);

const [draftCustomFont, setDraftCustomFont] = useState(
terminalCustomFontFamily,
Expand Down Expand Up @@ -67,10 +83,69 @@ export function TerminalSettings() {
setTerminalGpuRendering(enabled);
};

const handleDefaultCwdChange = (path: string) => {
track(ANALYTICS_EVENTS.SETTING_CHANGED, {
setting_name: "terminal_default_cwd",
new_value: path.length > 0,
old_value: terminalDefaultCwd.length > 0,
});
setTerminalDefaultCwd(path);
};

const handleChooseDefaultCwd = async () => {
if (isChoosingDirectory) return;
setIsChoosingDirectory(true);
try {
const path = await hostClient.os.selectDirectory.query();
if (path) handleDefaultCwdChange(path);
} catch (error) {
log.error("Failed to open directory picker", { error });
toast.error("Failed to open directory picker");
} finally {
setIsChoosingDirectory(false);
}
};

const showCustomInput = terminalFont === "custom";

return (
<Flex direction="column" gap="1" py="4">
{localWorkspaces && (
<SettingRow
label="Default directory"
description="Choose which directory new terminal sessions open in. Leave this unset to use the directory you most recently worked in."
>
<Flex align="center" gap="2" className="min-w-0">
{terminalDefaultCwd && (
<>
<Text
className="max-w-[220px] truncate text-[12px]"
title={terminalDefaultCwd}
>
{compactHomePath(terminalDefaultCwd)}
</Text>
<button
type="button"
aria-label="Clear default directory"
className="cursor-pointer p-0 opacity-60 hover:opacity-100"
onClick={() => handleDefaultCwdChange("")}
>
<X size={12} />
</button>
</>
)}
<Button
size="sm"
variant="outline"
loading={isChoosingDirectory}
onClick={() => void handleChooseDefaultCwd()}
>
{terminalDefaultCwd ? "Change…" : "Choose directory…"}
</Button>
</Flex>
</SettingRow>
)}

<SettingRow
label="Font"
description="Font used to render the terminal output"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ describe("feature settingsStore defaults", () => {
"local",
);
});

it("leaves the default terminal directory unset", () => {
expect(useSettingsStore.getState().terminalDefaultCwd).toBe("");
});
});

describe("feature settingsStore cloud selections", () => {
Expand Down Expand Up @@ -599,3 +603,42 @@ describe("feature settingsStore hydration", () => {
expect(useSettingsStore.getState()._hasHydrated).toBe(true);
});
});

describe("feature settingsStore terminal default directory", () => {
beforeEach(async () => {
await resetPersistenceMocks();

useSettingsStore.setState({ terminalDefaultCwd: "" });
});

it.each([
["a chosen directory", "/Users/luke/Documents/Jarvis"],
["a cleared directory", ""],
])("persists %s", async (_label, value) => {
useSettingsStore.getState().setTerminalDefaultCwd(value);

await waitForPersistedWrite();

const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1];
const persisted = JSON.parse(lastCall[1]);

expect(persisted.state.terminalDefaultCwd).toBe(value);
});

it("rehydrates the default terminal directory", async () => {
getItem.mockResolvedValue(
JSON.stringify({
state: { terminalDefaultCwd: "/Users/luke/Documents/Jarvis" },
version: 0,
}),
);

useSettingsStore.setState({ terminalDefaultCwd: "" });

await useSettingsStore.persist.rehydrate();

expect(useSettingsStore.getState().terminalDefaultCwd).toBe(
"/Users/luke/Documents/Jarvis",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,13 @@ interface SettingsStore {
terminalFont: TerminalFont;
terminalCustomFontFamily: string;
terminalGpuRendering: boolean;
// Directory new standalone terminals open in. Any path, not just a
// registered project folder. Empty means "infer from recent folders".
terminalDefaultCwd: string;
setTerminalFont: (font: TerminalFont) => void;
setTerminalCustomFontFamily: (value: string) => void;
setTerminalGpuRendering: (enabled: boolean) => void;
setTerminalDefaultCwd: (path: string) => void;

// Conversation thread (new-thread)
conversationCollapseMode: CollapseMode;
Expand Down Expand Up @@ -466,11 +470,13 @@ export const useSettingsStore = create<SettingsStore>()(
terminalFont: "berkeley-mono",
terminalCustomFontFamily: "",
terminalGpuRendering: true,
terminalDefaultCwd: "",
setTerminalFont: (font) => set({ terminalFont: font }),
setTerminalCustomFontFamily: (value) =>
set({ terminalCustomFontFamily: value }),
setTerminalGpuRendering: (enabled) =>
set({ terminalGpuRendering: enabled }),
setTerminalDefaultCwd: (path) => set({ terminalDefaultCwd: path }),

// Conversation thread (new-thread)
conversationCollapseMode: COLLAPSE_MODE_DEFAULT,
Expand Down Expand Up @@ -613,6 +619,7 @@ export const useSettingsStore = create<SettingsStore>()(
terminalFont: state.terminalFont,
terminalCustomFontFamily: state.terminalCustomFontFamily,
terminalGpuRendering: state.terminalGpuRendering,
terminalDefaultCwd: state.terminalDefaultCwd,

// Conversation thread (new-thread)
conversationCollapseMode: state.conversationCollapseMode,
Expand Down
Loading
Loading