feat: multi-project workspaces and project window groups (#448)

Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen.

Closes #241
Closes #198
Closes #286
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 22:45:42 +08:00
1 джерело 5bab140968
коміт 01defdefe4
88 files changed
+2689 -151

No files matched your search

+41 -1
Переглянути файл
@@ -11,6 +11,16 @@ export const commands = {
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
windowGroupCurrent: () => typedError<WindowTab[], IdeError>(__TAURI_INVOKE("window_group_current")),
windowGroupMerge: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_merge")),
windowGroupSelect: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_select", { label })),
windowGroupDetach: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_detach")),
windowGroupClose: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close", { label })),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
/**
* Edits the loose file `path` in this window without a project (LightEdit);
* a window already in LightEdit keeps its files. Returns the effective (global) settings.
@@ -22,6 +32,7 @@ export const commands = {
name: string,
isGit: boolean,
settings: Settings_Serialize,
workspace?: Workspace_Serialize | null,
} | null>("project_current"),
closeProject: () => __TAURI_INVOKE<void>("close_project"),
languages: () => __TAURI_INVOKE<LanguageInfo_Serialize[]>("languages"),
@@ -1270,6 +1281,8 @@ export type AgentSubagentStatus = "running" |
"needsInput" | "done" | "cancelled" | "failed";
export type AgentTask = {
/** Original project folder; absent in tasks saved before per-task roots. */
projectRoot?: IpcPath | null,
id: string,
title: string,
/** [`AgentProvider::id`]. */
@@ -3084,7 +3097,7 @@ export type FunctionBreakpoint = {
export type GeneralSettings = {
reopenLastProject?: boolean,
confirmExit?: boolean,
/** Where Open… puts a project while one is open: `ask` | `new` | `same`. */
/** Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`. */
openProjectIn?: string,
/** Initial folder of the Open / Clone dialogs; empty = the OS default. */
defaultProjectDirectory?: string,
@@ -3355,6 +3368,8 @@ export type IdeError = {
};
export type IdeEvent =
/** Project window membership or selection changed. */
{ type: "windowGroupsChanged" } |
/** Files or directories changed on disk (after debouncing, ignore rules applied). */
{ type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } |
/**
@@ -4220,6 +4235,8 @@ export type MethodStat = {
};
export type NewAgentTask = {
/** Validated by the host; defaults to the window project root. */
projectRoot?: IpcPath | null,
prompt: AgentPrompt,
provider: string,
/** Settings for the new session (model, mode…). */
@@ -4646,6 +4663,7 @@ export type ProjectInfo_Deserialize = {
name: string,
isGit: boolean,
settings: Settings_Deserialize,
workspace?: Workspace_Deserialize | null,
};
export type ProjectInfo_Serialize = {
@@ -4653,6 +4671,7 @@ export type ProjectInfo_Serialize = {
name: string,
isGit: boolean,
settings: Settings_Serialize,
workspace?: Workspace_Serialize | null,
};
export type ProjectRoots = {
@@ -6121,6 +6140,15 @@ export type WebReferenceKind =
/** `var(--name)`: the custom property's declarations. */
"cssVariable";
export type WindowTab = {
label: string,
name: string,
root: IpcPath | null,
current: boolean,
};
export type Workspace = Workspace_Serialize | Workspace_Deserialize;
/** Edits to many files, applied atomically from the user's point of view. */
export type WorkspaceEdit = {
files: FileEdit[],
@@ -6133,6 +6161,18 @@ export type WorkspaceSymbol = {
location: Location,
};
export type Workspace_Deserialize = {
version: number,
name: string,
projects: string[],
};
export type Workspace_Serialize = {
version: number,
name: string,
projects: string[],
};
export type Worktree = {
path: IpcPath,
/** Checked-out commit; `None` for a bare main repository. */
+26
Переглянути файл
@@ -170,6 +170,32 @@ describe("EditorController", () => {
expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]);
});
it("carries the plain project's editor tabs into its automatic workspace", async () => {
const settings = {};
useProject.setState({
project: { root: "/attach-a", name: "A", isGit: false, settings },
settings,
lightEdit: false,
});
ctrl = new EditorController(createMemoryBackend({ "/attach-a/one.txt": "one", "/attach-a/two.txt": "two" }));
ctrl.start();
await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false });
await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false });
useProject.setState({
project: {
root: "/attach-workspace",
name: "A",
isGit: false,
settings,
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
},
});
await vi.waitFor(() =>
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]),
);
useProject.setState({ project: null });
});
it("reopens the LightEdit files the next time a window switches to LightEdit", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b" }));
ctrl.start();
+9
Переглянути файл
@@ -262,6 +262,15 @@ export class EditorController implements SlotHost {
prevRoot = root;
prevLight = s.lightEdit;
this.saveSession();
if (
root &&
previous.project &&
!previous.project.workspace &&
s.project?.workspace?.projects[0] === previous.project.root
) {
const session = loadSession(previous.project.root);
if (session) storeSession(root, session);
}
this.sessionRoot = null;
const generation = ++this.projectGeneration;
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
+1
Переглянути файл
@@ -40,6 +40,7 @@ export function activate(): void | (() => void) {
activePath: () => ctrl.activePath(),
activeCursor: () => ctrl.activeCursor(),
saveAll: () => ctrl.saveAll(),
dirtyDocumentIds: () => [...ctrl.manager.byId.values()].filter((m) => m.getMeta().dirty).map((m) => m.doc),
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
openTabs: () => ctrl.openTabs(),
+3 -2
Переглянути файл
@@ -4,7 +4,7 @@ import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { basename, dirname, relativePath, useProject } from "@/platform/project";
import { basename, dirname, projectFolder, relativePath, useProject } from "@/platform/project";
import type { LineEnding, OutlineItem } from "@/platform/ipc";
import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
@@ -56,7 +56,8 @@ interface Crumb {
*/
export function Breadcrumbs() {
const path = useEditorStatus((s) => s.status?.path ?? null);
const project = useProject((s) => s.project);
const currentProject = useProject((s) => s.project);
const project = projectFolder(currentProject, path ?? "");
const outline = useOutline((s) => (path ? s[path] : undefined));
const chain = useCaretSymbols(path, outline) ?? NO_SYMBOLS;
const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null);
+18
Переглянути файл
@@ -74,3 +74,21 @@ describe("caret-driven status widgets", () => {
expect(commits).toBeGreaterThan(0);
});
});
it("shows the member and its directories in workspace breadcrumbs", async () => {
const { useProject } = await import("@/platform/project");
useProject.setState({
project: {
root: "/workspace",
name: "Workspace",
settings: {},
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] },
},
});
useEditorStatus.setState({ status: { ...caret(0, 0), path: "/b/src/file.ts" } });
const view = render(<Breadcrumbs />);
expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("bsrcfile.ts");
view.unmount();
useProject.setState({ project: null });
});
+8
Переглянути файл
@@ -50,6 +50,14 @@
border-radius: 50%;
background: var(--accent);
}
.taskProject {
flex: 0 0 auto;
max-width: 35%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-subtle);
}
.detail {
flex: 1 1 0;
min-width: 0;
+7
Переглянути файл
@@ -7,6 +7,7 @@ import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./mo
import { openHome, openTask, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
import { basename, useProject } from "@/platform/project";
import { useTx } from "@/platform/i18n";
export function AgentsHeaderActions() {
@@ -43,6 +44,7 @@ export function TaskStatusIcon({ status }: { status: Status }) {
}
export function AgentsView() {
const project = useProject((s) => s.project);
const tx = useTx();
const loaded = useAgents((s) => s.loaded);
const error = useAgents((s) => s.error);
@@ -129,6 +131,11 @@ export function AgentsView() {
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
+47 -15
Переглянути файл
@@ -4,8 +4,10 @@ import type { AgentProbe, Branch } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { basename, useProject } from "@/platform/project";
import { Button, Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { canSend, emptyDraft } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
@@ -22,8 +24,19 @@ export function HomeEditor() {
const history = useAgents((s) => s.history);
const composer = useAgents((s) => s.composers.get(HOME)) ?? emptyDraft();
const project = useProject((s) => s.project);
const [branches, setBranches] = useState<Branch[]>([]);
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState(() =>
defaultTaskProject(project, maybeEditorService()?.activePath() ?? null, draft.projectRoot),
);
const members = project?.workspace?.projects ?? [];
const root = members.includes(selected ?? "") ? selected! : (members[0] ?? project?.root ?? "");
const [repoState, setRepoState] = useState<{
root: string;
branches: Branch[];
current: string | null;
isGit: boolean;
} | null>(null);
const branches = repoState?.root === root ? repoState.branches : [];
const current = repoState?.root === root ? repoState.current : null;
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
@@ -35,31 +48,37 @@ export function HomeEditor() {
}, []);
useEffect(() => {
call(commands.vcsBranches(null))
.then((list) => {
const local = list.filter((b) => !b.remote);
setBranches(local);
setCurrent(local.find((b) => b.current)?.name ?? null);
let live = true;
const repo = project?.workspace ? root : null;
void Promise.all([call(commands.vcsBranchInfo(repo)), call(commands.vcsBranches(repo))])
.then(([info, list]) => {
if (!live) return;
setRepoState({ root, branches: list.filter((b) => !b.remote), current: info.head, isGit: true });
})
.catch(() => setBranches([]));
}, []);
.catch(() => {
if (live) setRepoState({ root, branches: [], current: null, isGit: false });
});
return () => {
live = false;
};
}, [root, project?.workspace]);
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
const probe = useProbe(provider?.installed ? provider.id : null);
const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS);
const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon);
// Without git under the project there are no worktrees: force the local checkout.
const isGit = project?.isGit ?? false;
const isGit = project?.workspace ? repoState?.root === root && repoState.isGit : (project?.isGit ?? false);
const isolation = isGit ? draft.isolation : "local";
const base = draft.base ?? current;
const canStart = canSend(composer) && !!provider?.installed && !starting;
const root = project?.root ?? "";
const canStart =
canSend(composer) && !!provider?.installed && !starting && (!project?.workspace || repoState?.root === root);
const start = () => {
if (!canStart) return;
setStarting(true);
setError(null);
startTask(isolation)
startTask(isolation, project?.workspace ? root : null)
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setStarting(false));
};
@@ -104,7 +123,20 @@ export function HomeEditor() {
}
/>
<div className={styles.where}>
<span className={styles.project}>{project?.name}</span>
{members.length > 1 ? (
<Select
variant="ghost"
aria-label={tx("Project")}
value={root}
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
onChange={(path) => {
setSelected(path);
updateDraft({ base: null });
}}
/>
) : (
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
)}
{isGit && (
<Select
variant="ghost"
+11 -4
Переглянути файл
@@ -72,7 +72,11 @@ export function SessionEditor({ input }: { input: EditorInput }) {
const task = useAgents((s) => s.tasks.get(id));
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
const head = useLocalHead(task?.isolation === "local" ? task.status : null);
const project = useProject((s) => s.project);
const head = useLocalHead(
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
@@ -160,14 +164,14 @@ function providerName(id: string) {
* Current branch of the project's repository for a local-checkout task (`status` is
* `null` otherwise); followed as git changes, since the agent or the user may switch it.
*/
function useLocalHead(status: AgentTask["status"] | null): string | null {
function useLocalHead(status: AgentTask["status"] | null, root: string | null): string | null {
const [head, setHead] = useState<string | null>(null);
const local = status !== null;
useEffect(() => {
if (!local) return;
let live = true;
const load = () =>
void call(commands.vcsBranchInfo(null)).then(
void call(commands.vcsBranchInfo(root)).then(
(b) => live && setHead(b.head ?? b.headCommit?.slice(0, 8) ?? null),
() => live && setHead(null),
);
@@ -177,7 +181,7 @@ function useLocalHead(status: AgentTask["status"] | null): string | null {
live = false;
off();
};
}, [local, status]);
}, [local, status, root]);
return local ? head : null;
}
@@ -196,6 +200,8 @@ function Header({
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const project = useProject((s) => s.project);
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
const count = changes?.length ?? 0;
const busy = isBusy(task.status);
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
@@ -216,6 +222,7 @@ function Header({
<span className={styles.headerText}>
{providerName(task.provider)}
{task.model ? ` · ${task.model}` : ""}
{projectName ? ` · ${projectName}` : ""}
{" · "}
{where ?? (
<>
+54 -2
Переглянути файл
@@ -5,17 +5,20 @@ import type { AgentCommand, AgentProvider } from "@/bindings";
const agentCommands = vi.fn();
const agentCreate = vi.fn();
const vcsBranches = vi.fn();
const vcsBranchInfo = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
isTauri: vi.fn(() => true),
commands: {
...real.commands,
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
agentCreate: (request: unknown) => agentCreate(request),
vcsBranches: () => Promise.resolve({ status: "ok", data: [] }),
vcsBranchInfo: (root: string | null) => vcsBranchInfo(root),
vcsBranches: (root: string | null) => vcsBranches(root),
},
};
});
@@ -23,6 +26,10 @@ vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import type { ProjectInfo } from "@/bindings";
import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
import { HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
@@ -40,6 +47,14 @@ const claude: AgentProvider = {
};
beforeEach(() => {
vi.mocked(isTauri).mockReturnValue(true);
useProject.setState({ project: null });
vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] });
vcsBranchInfo
.mockReset()
.mockImplementation((root: string | null) =>
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
);
agentCommands.mockReset();
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
useAgents.setState({
@@ -105,3 +120,40 @@ describe("New Task composer commands", () => {
expect(agentCreate).not.toHaveBeenCalled();
});
});
describe("workspace task project", () => {
const project = {
root: "/workspace",
name: "Workspace",
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
} as ProjectInfo;
it("defaults to the active member, last used member, then first member", () => {
expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b");
expect(defaultTaskProject(project, null, "/project-b")).toBe("/project-b");
expect(defaultTaskProject(project, "/project-b-other/file", "/removed")).toBe("/project-a");
expect(defaultTaskProject({ ...project, workspace: null }, "/project-b/file", "/project-b")).toBe("/workspace");
});
it.each(["local", "worktree"] as const)("sends the selected project with %s isolation", async (isolation) => {
vi.mocked(isTauri).mockReturnValue(false);
useProject.setState({ project });
agentCommands.mockResolvedValue({ status: "ok", data: [] });
useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } });
render(<HomeEditor />);
const selector = screen.getByRole("button", { name: "Project" });
expect(selector).toHaveTextContent("project-b");
await waitFor(() => expect(vcsBranchInfo).toHaveBeenCalledWith("/project-b"));
expect(vcsBranches).toHaveBeenCalledWith("/project-b");
fireEvent.click(selector);
expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument();
fireEvent.click(screen.getByRole("option", { name: /project-a/ }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled());
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation });
expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a");
expect(vcsBranches).toHaveBeenLastCalledWith("/project-a");
await vi.dynamicImportSettled();
});
});
+13
Переглянути файл
@@ -0,0 +1,13 @@
import { projectFolder } from "@/platform/project";
/** Prefer the active file's member, then the last task's member, then manifest order. */
export function defaultTaskProject(
project: Parameters<typeof projectFolder>[0],
activePath: string | null,
last: string | null | undefined,
): string | null {
const members = project?.workspace?.projects;
if (!members?.length) return project?.root ?? null;
const active = activePath ? projectFolder(project, activePath)?.root : null;
return active ?? (last && members.includes(last) ? last : (members[0] ?? null));
}
+15 -1
Переглянути файл
@@ -12,6 +12,8 @@ import type {
AgentTask,
Isolation,
} from "@/bindings";
import { useProject } from "@/platform/project";
import { defaultTaskProject } from "./project";
import { call, commands, isTauri } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
@@ -29,6 +31,7 @@ import {
/** New Task page settings (its text lives in the `HOME` composer). */
export interface Draft {
projectRoot?: string | null;
provider: string;
/** Settings chosen for the next task, by option id (reset when the agent changes). */
config: Record<string, string>;
@@ -343,18 +346,28 @@ export function openTask(task: string) {
/** Opens the New Task page; `prompt` pre-fills it. */
export function openHome(prompt?: string) {
const project = useProject.getState().project;
if (project?.workspace) {
const previous = useAgents.getState().draft.projectRoot;
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
/** `isolation` overrides the draft's (a project without git can only use the local checkout). */
export async function startTask(isolation: Isolation = useAgents.getState().draft.isolation): Promise<void> {
export async function startTask(
isolation: Isolation = useAgents.getState().draft.isolation,
projectRoot: string | null = null,
): Promise<void> {
const { draft, providers } = useAgents.getState();
const composer = composerDraft(HOME);
if (!canSend(composer)) return;
const config = { ...bypassDefaults(providers.find((p) => p.id === draft.provider)?.config ?? []), ...draft.config };
const task = await call(
commands.agentCreate({
projectRoot,
prompt: toPrompt(composer),
provider: draft.provider,
config: Object.entries(config).map(([id, value]) => ({ id, value })),
@@ -367,6 +380,7 @@ export async function startTask(isolation: Isolation = useAgents.getState().draf
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
updateComposer(HOME, emptyDraft());
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
+68
Переглянути файл
@@ -0,0 +1,68 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
}
.field {
display: grid;
grid-template-columns: 80px 1fr;
align-items: center;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.row > input {
flex: 1;
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.projects {
min-height: 130px;
max-height: 260px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
.project {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
}
.project + .project {
border-top: 1px solid var(--border);
}
.path {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.path span {
color: var(--fg-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
color: var(--fg-muted);
margin: 0;
line-height: 1.5;
}
.projects > .hint {
padding: 16px;
}
.error {
color: var(--danger);
}
+172
Переглянути файл
@@ -0,0 +1,172 @@
import { useState } from "react";
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import { basename, dirname, useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { tx } from "@/platform/i18n";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import { openProjectInWindow } from "./windows";
import styles from "./WorkspaceDialog.module.css";
const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null }));
export const showWorkspaceDialog = (mode: "create" | "manage") => useWorkspaceDialog.setState({ mode });
export function WorkspaceDialogHost() {
const mode = useWorkspaceDialog((s) => s.mode);
return mode ? <WorkspaceDialog mode={mode} onClose={() => useWorkspaceDialog.setState({ mode: null })} /> : null;
}
function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose: () => void }) {
const project = useProject.getState().project;
const workspace = mode === "manage" ? project?.workspace : null;
const [name, setName] = useState(workspace?.name ?? "Workspace");
const [path, setPath] = useState(
workspace ? project!.root : project && !isRemoteWindow() ? `${dirname(project.root)}/workspace` : "",
);
const [projects, setProjects] = useState<string[]>(
workspace?.projects ?? (project && !isRemoteWindow() && !project.workspace ? [project.root] : []),
);
const [adding, setAdding] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const add = (value: string) => {
const path = value.trim();
if (path && !projects.includes(path)) setProjects([...projects, path]);
setAdding("");
};
const browse = async (target: "workspace" | "project") => {
const { open } = await import("@tauri-apps/plugin-dialog");
const result = await open({
directory: true,
multiple: target === "project",
title: tx(target === "project" ? "Add Projects" : "Workspace Location"),
});
const paths = result === null ? [] : Array.isArray(result) ? result : [result];
if (target === "workspace" && paths[0]) setPath(paths[0]);
else setProjects((current) => [...new Set([...current, ...paths])]);
};
const save = async () => {
if (busy) return;
setBusy(true);
setError("");
try {
const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects;
if (mode === "create") {
const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included));
await openProjectInWindow(root, !!project);
} else {
const updated = await call(commands.workspaceUpdate(name.trim(), included));
useProject.setState({ project: updated, settings: updated.settings });
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<Dialog
open
onClose={() => {
if (!busy) onClose();
}}
title={mode === "create" ? "New Workspace" : "Manage Workspace"}
width={640}
buttons={[
{ label: "Cancel", role: "cancel", disabled: busy },
{
label: mode === "create" ? "Create" : "Apply",
role: "default",
disabled: busy || !name.trim() || !path.trim(),
onClick: () => void save(),
},
]}
>
<div className={styles.body}>
<label className={styles.field}>
<T>Name</T>
<Input aria-label="Workspace name" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
</label>
<label className={styles.field}>
<T>Location</T>
<div className={styles.row}>
<Input
aria-label="Workspace location"
value={path}
disabled={busy || mode === "manage"}
onChange={(e) => setPath(e.target.value)}
/>
{mode === "create" && (
<IconButton
icon="folder-open"
title="Choose workspace location"
onClick={() => void browse("workspace").catch((e) => setError(String(e)))}
/>
)}
</div>
</label>
<div className={styles.heading}>
<T>Linked projects</T>
<Button
size="small"
icon="plus"
disabled={busy}
onClick={() => void browse("project").catch((e) => setError(String(e)))}
>
Add Projects…
</Button>
</div>
<div className={styles.projects} role="list" aria-label="Linked projects">
{projects.map((root) => (
<div className={styles.project} role="listitem" key={root}>
<Icon name="folder" size={16} />
<div className={styles.path}>
<strong>{basename(root)}</strong>
<span title={root}>{root}</span>
</div>
<IconButton
icon="close"
title={tx`Remove ${basename(root)}`}
disabled={busy}
onClick={() => setProjects(projects.filter((p) => p !== root))}
/>
</div>
))}
{!projects.length && (
<p className={styles.hint}>
<T>Add the projects you want to work on together.</T>
</p>
)}
</div>
<div className={styles.row}>
<Input
aria-label="Project folder path"
placeholder="Project folder path"
value={adding}
disabled={busy}
onChange={(e) => setAdding(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
add(adding);
}
}}
/>
<Button disabled={busy || !adding.trim()} onClick={() => add(adding)}>
Add
</Button>
</div>
<p className={styles.hint}>
<T>Project folders stay in place. Removing a project here keeps its files.</T>
</p>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
</div>
</Dialog>
);
}
+42
Переглянути файл
@@ -0,0 +1,42 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog";
import { openProjectInWindow } from "../windows";
vi.mock("../windows", () => ({ openProjectInWindow: vi.fn(async () => {}) }));
vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false }));
beforeEach(() => {
vi.restoreAllMocks();
useProject.setState({ project: null, settings: null, lightEdit: false });
});
it("creates a workspace from the pending project field without an extra Add click", async () => {
const save = vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" });
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } });
fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/frontend" } });
fireEvent.click(screen.getByRole("button", { name: /^Create$/ }));
await waitFor(() => expect(save).toHaveBeenCalledWith("/work/full", "Workspace", ["/work/frontend"]));
expect(openProjectInWindow).toHaveBeenCalledWith("/work/full", false);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("retains the form and linked folders when saving fails", async () => {
vi.spyOn(commands, "workspaceCreate").mockResolvedValue({
status: "error",
error: { kind: "invalidArgument", message: "Folder does not exist" },
} as never);
showWorkspaceDialog("create");
render(<WorkspaceDialogHost />);
fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } });
fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/missing" } });
fireEvent.click(screen.getByRole("button", { name: /^Create$/ }));
expect(await screen.findByRole("alert")).toHaveTextContent("Folder does not exist");
expect(screen.getByLabelText("Project folder path")).toHaveValue("/work/missing");
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
+21
Переглянути файл
@@ -158,3 +158,24 @@ describe("decideClose", () => {
expect(text).toBe("disk baseline");
});
});
it("includes pending local edits and restored dirty buffers before backend dirty events", async () => {
const service = await import("@/workbench/contributions");
const { dirtyCount, installCloseGuard, confirmLeave } = await import("../closeGuard");
const { emitLocalIdeEvent } = await import("@/platform/ipc");
const { d, asked } = deps([{ button: "cancel", checked: false }]);
const uninstall = installCloseGuard(d);
const spy = vi.spyOn(service, "maybeEditorService").mockReturnValue({ dirtyDocumentIds: () => [10, 11] } as never);
try {
emitLocalIdeEvent({ type: "docDirty", doc: 11, dirty: true });
emitLocalIdeEvent({ type: "docDirty", doc: 12, dirty: true });
expect(dirtyCount()).toBe(3);
await expect(confirmLeave(d)).resolves.toBe(false);
expect(asked[0]?.body).toContain("3");
} finally {
emitLocalIdeEvent({ type: "docDirty", doc: 11, dirty: false });
emitLocalIdeEvent({ type: "docDirty", doc: 12, dirty: false });
uninstall();
spy.mockRestore();
}
});
+68 -2
Переглянути файл
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const workspaceAttach = vi.fn();
const workspaceUpdate = vi.fn();
const projectCurrent = vi.fn();
const windowOpenProject = vi.fn();
const windowOpenFiles = vi.fn();
const windowTakeLaunch = vi.fn();
@@ -19,6 +22,9 @@ vi.mock("@/platform/ipc", async (orig) => {
isTauri: () => true,
commands: {
...real.commands,
workspaceAttach: (...a: unknown[]) => workspaceAttach(...a),
workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a),
projectCurrent: () => projectCurrent(),
windowOpenProject: (...a: unknown[]) => windowOpenProject(...a),
windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a),
windowTakeLaunch: () => windowTakeLaunch(),
@@ -32,8 +38,15 @@ vi.mock("@/platform/project", async (orig) => {
return { ...real, openProject: (p: string) => openProject(p) };
});
const { openProjectInWindow, consumeLaunch, openFiles, openPaths, openLightEditFileAsProject, windowActions } =
await import("../windows");
const {
openProjectInWindow,
consumeLaunch,
openFiles,
openPaths,
openLightEditFileAsProject,
openProjectWithPolicy,
windowActions,
} = await import("../windows");
const { useProject } = await import("@/platform/project");
const { provideEditorService } = await import("@/workbench/contributions");
@@ -216,3 +229,56 @@ describe("launch", () => {
expect(opened).toEqual(["/x/notes/a.txt (focus)"]);
});
});
describe("Attach", () => {
it("switches a plain project to the automatic workspace in place", async () => {
useProject.setState({
...projectState("/p/a"),
settings: { general: { openProjectIn: "attach" } },
} as never);
workspaceAttach.mockResolvedValue(ok("/data/workspaces/a"));
await openProjectWithPolicy("/p/b");
expect(workspaceAttach).toHaveBeenCalledWith("/p/b");
expect(openProject).toHaveBeenCalledWith("/data/workspaces/a");
expect(windowOpenProject).not.toHaveBeenCalled();
});
it("refreshes an existing workspace without reopening its editor session", async () => {
const before = {
root: "/workspace",
name: "A",
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/p/a"] },
};
useProject.setState({
project: before,
settings: { general: { openProjectIn: "attach" } },
} as never);
workspaceAttach.mockResolvedValue(ok("/workspace"));
projectCurrent.mockResolvedValue({
...before,
workspace: { ...before.workspace, projects: ["/p/a", "/p/b"] },
});
await openProjectWithPolicy("/p/b");
expect(useProject.getState().project?.workspace?.projects).toEqual(["/p/a", "/p/b"]);
expect(openProject).not.toHaveBeenCalled();
});
});
it("removes the first workspace member while retaining the workspace state root", async () => {
const project = {
root: "/workspace",
name: "A",
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
};
useProject.setState({ project });
workspaceUpdate.mockResolvedValue(ok({ ...project, workspace: { ...project.workspace, projects: ["/b"] } }));
await windowActions()
.find((a) => a.id === "workspace.removeProject")!
.run({ arg: "/a" } as never);
expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/b"]);
expect(useProject.getState().project?.root).toBe("/workspace");
expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]);
});
+6 -2
Переглянути файл
@@ -16,6 +16,10 @@ import { ask, type Ask } from "@/workbench/messages";
/** Document ids the backend reports dirty in this window. */
const dirtyDocs = new Set<number>();
export function dirtyCount(): number {
return new Set([...dirtyDocs, ...(maybeEditorService()?.dirtyDocumentIds?.() ?? [])]).size;
}
export function confirmExitEnabled(settings: Settings | null | undefined): boolean {
return settings?.general?.confirmExit === true;
}
@@ -116,7 +120,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
quitting: ctx.quitting,
windows: ctx.windows,
confirmExit: confirmExitEnabled(s.settings),
dirty: dirtyDocs.size,
dirty: dirtyCount(),
project: s.project?.name ?? null,
},
deps,
@@ -136,7 +140,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
export function confirmLeave(deps: CloseDeps = defaultDeps): Promise<boolean> {
const s = useProject.getState();
return decideClose(
{ quitting: false, windows: 2, confirmExit: false, dirty: dirtyDocs.size, project: s.project?.name ?? null },
{ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null },
deps,
);
}
+4
Переглянути файл
@@ -17,6 +17,8 @@ import { NewProjectOverlay } from "./NewProject";
import { projectActions, registerPowerSaveBanner } from "./projectActions";
import { installWindowTitle } from "./title";
import { installLaunchHandling, installLightEditAutoClose, windowActions } from "./windows";
import { WorkspaceDialogHost } from "./WorkspaceDialog";
import { installWindowGroups } from "@/platform/windowGroups";
/** The UI locale of a secondary (Settings) window. */
export const activateLocale = installLocaleSync;
@@ -43,6 +45,8 @@ export function activate(): () => void {
installWindowTitle(),
installCloseGuard(),
installLaunchHandling(),
installWindowGroups(),
registerOverlay({ id: "app.workspace", component: WorkspaceDialogHost }),
installLightEditAutoClose(),
];
let disposed = false;
+127 -1
Переглянути файл
@@ -7,7 +7,7 @@
* Open File…, a file path on the command line) open in LightEdit: a window with
* editor tabs only. One LightEdit window collects them all.
*/
import type { Action } from "@/platform/actions";
import { executeAction, type Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import {
@@ -23,6 +23,8 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { chooseProjectWindow } from "@/workbench/projectWindow";
import { cycleWindowTab } from "@/platform/windowGroups";
import { showWorkspaceDialog } from "./WorkspaceDialog";
export const LAUNCH_EVENT = "openidea://launch";
@@ -137,9 +139,32 @@ export async function openProjectWithPolicy(path: string, files: readonly string
const { project: current, lightEdit } = useProject.getState();
if (!current && !lightEdit) return openProjectInWindow(path, false, files);
if (current?.root === path) return openInEditor(files);
if (current?.workspace?.projects.includes(path)) {
await executeAction("project.reveal", path);
return openInEditor(files);
}
const mode = await chooseProjectWindow(basename(path));
if (!mode) return;
if (mode === "new") return openProjectInWindow(path, true, files);
if (mode === "attach") {
try {
if (!current?.workspace && !(await confirmLeaveProject())) return;
const root = await call(commands.workspaceAttach(path));
if (root === current?.root) {
if (!current.workspace) maybeEditorService()?.cancelClose?.();
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
} else {
await openProject(root);
}
await executeAction("project.reveal", path);
await openInEditor(files);
} catch (e) {
maybeEditorService()?.cancelClose?.();
openFailed(path, e);
}
return;
}
try {
// A window that already has the project is focused instead.
const outcome = await call(commands.windowOpenProject(path, false, [...files]));
@@ -197,6 +222,107 @@ export async function consumeLaunch(): Promise<void> {
export function windowActions(): Action[] {
return [
{
id: "workspace.removeProject",
title: "Remove from Workspace",
category: t("app.category.file"),
when: "workspaceOpen",
run: async ({ arg }) => {
const workspace = useProject.getState().project?.workspace;
if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return;
const project = await call(
commands.workspaceUpdate(
workspace.name,
workspace.projects.filter((p) => p !== arg),
),
);
useProject.setState({ project, settings: project.settings });
},
},
{
id: "workspace.create",
title: "New Workspace…",
category: t("app.category.file"),
icon: "folder",
run: () => {
if (!desktopOnly()) showWorkspaceDialog("create");
},
},
{
id: "workspace.open",
title: "Open Workspace…",
category: t("app.category.file"),
icon: "folder-open",
run: async () => {
if (desktopOnly()) return;
const path = await pickFolder("Open Workspace");
if (path) await openProjectWithPolicy(path);
},
},
{
id: "workspace.manage",
title: "Manage Workspace…",
category: t("app.category.file"),
when: "workspaceOpen",
run: () => showWorkspaceDialog("manage"),
},
{
id: "window.mergeAllWindows",
title: "Merge All Project Windows",
category: t("app.category.window"),
when: "projectOpen",
run: async () => {
if (!desktopOnly()) await call(commands.windowGroupMerge());
},
},
{
id: "window.detachTab",
title: "Move Tab to New Window",
category: t("app.category.window"),
when: "windowGrouped",
run: () => call(commands.windowGroupDetach()),
},
{
id: "window.selectTab",
title: "Select Project Tab",
hidden: true,
when: "windowGrouped",
run: async ({ arg }) => {
if (typeof arg === "string") await call(commands.windowGroupSelect(arg));
},
},
{
id: "window.closeTab",
title: "Close Project Tab",
hidden: true,
when: "windowGrouped",
run: async ({ arg }) => {
if (typeof arg === "string") await call(commands.windowGroupClose(arg));
},
},
{
id: "window.reorderTabs",
title: "Reorder Project Tabs",
hidden: true,
when: "windowGrouped",
run: async ({ arg }) => {
if (Array.isArray(arg) && arg.every((v) => typeof v === "string")) await call(commands.windowGroupReorder(arg));
},
},
{
id: "window.nextTab",
title: "Next Project Tab",
category: t("app.category.window"),
when: "windowGrouped",
run: () => cycleWindowTab(true),
},
{
id: "window.previousTab",
title: "Previous Project Tab",
category: t("app.category.window"),
when: "windowGrouped",
run: () => cycleWindowTab(false),
},
{
id: "file.openInNewWindow",
title: t("app.action.openInNewWindow"),
+3 -1
Переглянути файл
@@ -32,9 +32,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
.rootLabel {
.rootName {
flex: none;
max-width: 60%;
}
.rootLabel {
color: var(--fg-strong);
font-weight: 600;
}
+8
Переглянути файл
@@ -243,3 +243,11 @@ describe("ProjectView", () => {
expect(rendered).toBeLessThan(80);
});
});
it("renders workspace member roots with their own paths, without the settings directory", async () => {
fs = new MemoryFs({ "/z/first.txt": "", "/a/second.txt": "", "/workspace/.openidea/workspace.json": "{}" });
setProjectFsForTests(fs, "/workspace");
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
const { rows } = setup();
await waitFor(() => expect(rows()).toEqual(["z/z", "first.txt", "a/a", "second.txt"]));
});
+3 -3
Переглянути файл
@@ -279,6 +279,7 @@ const Row = memo(function Row({
<HighlightedText
className={clsx(
styles.label,
node.root && styles.rootName,
// Only the project itself is bold (IDEA); contributed roots like Scratches are not.
node.root && !node.icon && styles.rootLabel,
faded && !deco.color && styles.excluded,
@@ -604,7 +605,6 @@ export function ProjectView() {
};
const cutPaths = useMemo(() => new Set(clipboard?.cut ? clipboard.paths : []), [clipboard]);
const rootDetail = root ? tildify(root, home) : null;
const renderNode = useCallback(
(node: ProjectNode, state: TreeRowState) => (
@@ -614,14 +614,14 @@ export function ProjectView() {
editing={editing}
cut={cutPaths.has(node.path)}
dropTarget={dropTarget === node.key}
detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)}
detail={node.root && !node.icon ? tildify(node.path, home) : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onDoubleClick={onRowDoubleClick}
onPointerDown={onRowPointerDown}
/>
),
[editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown],
[editing, cutPaths, dropTarget, home, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown],
);
if (!root) return null;
+24
Переглянути файл
@@ -201,3 +201,27 @@ describe("project view context menu", () => {
expect(l).not.toContain("Find in Files…");
});
});
it("offers workspace removal for every member root", () => {
useProject.setState({
project: {
root: "/workspace",
name: "A",
settings: {},
isGit: false,
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
},
});
const removal = {
kind: "action",
action: "workspace.removeProject",
arg: "/b",
};
expect(contextMenuItems([node("/b", true, true)])).toContainEqual(removal);
expect(contextMenuItems([node("/a", true, true)])).toContainEqual(
expect.objectContaining({ action: "workspace.removeProject" }),
);
expect(contextMenuItems([node("/b/src", true)])).not.toContainEqual(
expect.objectContaining({ action: "workspace.removeProject" }),
);
});
+4
Переглянути файл
@@ -152,6 +152,10 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
if (single && project?.workspace && project.workspace.projects.includes(single.path)) {
push({ kind: "action", action: "workspace.removeProject", arg: single.path }, { kind: "separator" });
}
push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" });
// Cut / Copy / Copy Path/Reference… / Paste
+4 -2
Переглянути файл
@@ -29,7 +29,8 @@ export function activate(): () => void {
const sync = () => {
const p = useProject.getState().project;
const s = useProjectTree.getState();
if ((p?.root ?? null) !== s.root) initProjectTree(p?.root ?? null, p?.name ?? "");
if ((p?.root ?? null) !== s.root || p?.workspace?.projects !== (s.members ?? undefined))
initProjectTree(p?.root ?? null, p?.name ?? "", p?.workspace?.projects ?? null);
else if (p && p.name !== s.name) useProjectTree.setState({ name: p.name });
};
sync();
@@ -60,7 +61,8 @@ export function activate(): () => void {
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const paths = project ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const paths =
project && !project.workspace ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const key = JSON.stringify(paths);
if (key === shownRoots) return;
shownRoots = key;
+1 -1
Переглянути файл
@@ -90,7 +90,7 @@ export function topLevelPaths(nodes: ProjectNode[] = selectedNodes()): string[]
export function targetFolder(node: ProjectNode | null = leadNode()): string | null {
const { root } = get();
if (!root) return null;
if (!node) return root;
if (!node) return get().members?.[0] ?? (get().members ? null : root);
return node.dir ? node.path : parentOf(node.path);
}
+18 -1
Переглянути файл
@@ -9,7 +9,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
import { beginNewPackage, checkEditingName, commitEditing } from "./operations";
import { packageKindAt } from "./packages";
import { packageSegments } from "./paths";
import { initProjectTree, loadDir } from "./store";
import { initProjectTree, loadDir, selectModel, useProjectTree } from "./store";
const root = (path: string, kind: RootFolder["kind"], detectedFrom: string | null): RootFolder => ({
path,
@@ -103,3 +103,20 @@ describe("creating packages", () => {
expect(fs.nodes.has("/p/pkg/api/v1/__init__.py")).toBe(true);
});
});
it("uses only manifest members in order in workspace Project and Project Files views", () => {
for (const mode of ["project", "projectFiles"] as const) {
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
useProjectTree.setState({ mode });
const model = selectModel(useProjectTree.getState());
expect(model.roots.map((r) => r.path)).toEqual(["/z", "/a"]);
expect(model.byKey.has("/workspace")).toBe(false);
initProjectTree("/workspace", "Workspace", ["/a"]);
expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]);
initProjectTree("/workspace", "Workspace", []);
expect(selectModel(useProjectTree.getState()).roots).toEqual([]);
}
initProjectTree("/a", "A");
expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]);
initProjectTree(null);
});
+23 -7
Переглянути файл
@@ -87,6 +87,7 @@ export interface Clipboard {
interface ProjectTreeState {
root: string | null;
name: string;
members: readonly string[] | null;
listings: ReadonlyMap<string, readonly FileEntry[]>;
expanded: ReadonlySet<string>;
selected: ReadonlySet<string>;
@@ -130,6 +131,7 @@ function writeJson(key: string, value: unknown) {
export const useProjectTree = create<ProjectTreeState>(() => ({
root: null,
name: "",
members: null,
listings: new Map(),
expanded: new Set(),
selected: new Set(),
@@ -158,6 +160,7 @@ const set = (patch: Partial<ProjectTreeState>) => useProjectTree.setState(patch)
let memo: {
root: string | null;
name: string;
members: readonly string[] | null;
listings: ProjectTreeState["listings"];
options: ViewOptions;
editing: Editing | null;
@@ -178,6 +181,7 @@ export function selectModel(s: ProjectTreeState): TreeModel {
memo &&
memo.root === s.root &&
memo.name === s.name &&
memo.members === s.members &&
memo.listings === s.listings &&
memo.options === s.options &&
memo.editing === s.editing &&
@@ -190,7 +194,16 @@ export function selectModel(s: ProjectTreeState): TreeModel {
s.editing?.kind === "new" ? { parent: s.editing.parent, kind: s.editing.nodeKind } : null;
const extras = useProjectViewRoots.getState().roots;
const contributed = projectViewRoots(extras);
const project = () => buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder);
const memberRoots = s.members?.map((path) => ({
path,
label: path.slice(path.lastIndexOf("/") + 1),
icon: "",
detail: path,
}));
const project = () =>
memberRoots
? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, s.options, placeholder)
: buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder);
let model = EMPTY_MODEL;
if (s.root) {
if (s.mode === "projectFiles") model = project();
@@ -204,7 +217,7 @@ export function selectModel(s: ProjectTreeState): TreeModel {
);
else if (files)
model = buildPathsModel(
[{ path: s.root, label: s.name, icon: "" }, ...extraFolders(contributed)],
[...(memberRoots ?? [{ path: s.root, label: s.name, icon: "" }]), ...extraFolders(contributed)],
files,
s.options.foldersFirst,
);
@@ -217,6 +230,7 @@ export function selectModel(s: ProjectTreeState): TreeModel {
memo = {
root: s.root,
name: s.name,
members: s.members,
listings: s.listings,
options: s.options,
editing: s.editing,
@@ -238,7 +252,7 @@ const inflight = new Map<string, Promise<void>>();
let persistTimer: ReturnType<typeof setTimeout> | undefined;
/** Resets the view for a newly opened project, restoring its expansion state. */
export function initProjectTree(root: string | null, name = "") {
export function initProjectTree(root: string | null, name = "", members: readonly string[] | null = null) {
generation++;
inflight.clear();
clearTimeout(persistTimer);
@@ -247,8 +261,9 @@ export function initProjectTree(root: string | null, name = "") {
set({
root,
name,
members,
listings: new Map(),
expanded: initialExpanded(root, get().mode),
expanded: initialExpanded(root, get().mode, members),
selected: new Set(),
lead: null,
editing: null,
@@ -264,7 +279,7 @@ const modeExpanded = new Map<ViewMode, ReadonlySet<string>>();
const autoExpanded = new Set<string>();
/** "Project" restores the persisted expansion; other panes start with their roots open. */
function initialExpanded(root: string | null, mode: ViewMode): Set<string> {
function initialExpanded(root: string | null, mode: ViewMode, members = get().members): Set<string> {
if (!root || isFilteredMode(mode)) return new Set();
if (mode === "scratches")
return new Set(
@@ -273,8 +288,9 @@ function initialExpanded(root: string | null, mode: ViewMode): Set<string> {
),
);
const persisted = mode === "project" ? (readJson<string[]>(expandedKey(root)) ?? []) : [];
const expanded = new Set(persisted.filter((p) => isSameOrInside(p, root)));
expanded.add(root);
const roots = members ?? [root];
const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r))));
roots.forEach((r) => expanded.add(r));
return expanded;
}
+4
Переглянути файл
@@ -32,6 +32,10 @@ describe("Find in Files module and custom scopes", () => {
const base = useFindPopup.getState();
it("a module searches its folder, the root module the project", () => {
expect(resolveFindScope({ ...base, scopeTab: "module", module: "/member" }, inputs)).toEqual({
kind: "directory",
path: "/member",
});
expect(resolveFindScope({ ...base, scopeTab: "module", module: "web" }, inputs)).toEqual({
kind: "directory",
path: "/p/web",
+10 -3
Переглянути файл
@@ -105,7 +105,9 @@ function displayDirectory(dir: string, root: string | null): string {
function FindInFilesDialog() {
const s = useFindPopup();
const txt = useTxTemplate();
const root = useProject((p) => p.project?.root ?? null);
const project = useProject((p) => p.project);
const root = project?.workspace?.projects[0] ?? (project?.workspace ? null : (project?.root ?? null));
const workspace = useProject((p) => !!p.project?.workspace);
const editors = useEditorRegistry((r) => r.editors);
const modules = useProjectModules();
const customScopes = useCustomScopeList();
@@ -133,6 +135,10 @@ function FindInFilesDialog() {
const set = (patch: Partial<FindOptions>) => useFindPopup.setState(patch);
useEffect(() => () => session.dispose(), [session]);
useEffect(() => {
if (modules.length && !modules.some((m) => m.path === s.module))
useFindPopup.setState({ module: modules[0]!.path });
}, [modules, s.module]);
// Hidden popup actions (⌘G, F4, ⌥↓ …) win over the editor's bindings while this is set.
useEffect(() => {
@@ -590,7 +596,8 @@ function FindInFilesDialog() {
// IDEA starts the Directory scope at the current file's folder.
if (id === "directory" && !s.directory.trim()) {
const active = maybeEditorService()?.activePath();
const dir = active && root && active.startsWith(root + "/") ? dirname(active) : root;
const folders = project?.workspace?.projects ?? (root ? [root] : []);
const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root;
set({ scopeTab: "directory", directory: dir ?? "" });
} else set({ scopeTab: id as ScopeTab });
}}
@@ -698,7 +705,7 @@ function FindInFilesDialog() {
<T>replaced ·</T>{" "}
</span>
)}
{basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
</span>
</>
);
+4 -2
Переглянути файл
@@ -37,7 +37,7 @@ export interface FindOptions {
maskEnabled: boolean;
mask: string;
scopeTab: ScopeTab;
/** Module tab: project-relative folder of the module (`""`: the project's root module). */
/** Module tab: relative module folder or absolute workspace member (`""`: plain project root). */
module: string;
directory: string;
namedScope: NamedScope;
@@ -186,7 +186,9 @@ export function resolveFindScope(o: FindOptions, inputs: ScopeInputs): SearchSco
if (o.scopeTab === "directory" && o.directory.trim())
return { kind: "directory", path: resolveDirectory(o.directory, root) };
if (o.scopeTab === "module")
return o.module && root ? { kind: "directory", path: `${root}/${o.module}` } : { kind: "project" };
return o.module && root
? { kind: "directory", path: o.module.startsWith("/") ? o.module : `${root}/${o.module}` }
: { kind: "project" };
if (o.scopeTab !== "scope") return { kind: "project" };
const custom = customScopeName(o.namedScope);
if (custom !== null) {
+2 -2
Переглянути файл
@@ -7,7 +7,7 @@
import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes";
export type Antialiasing = "subpixel" | "greyscale" | "none";
export type OpenProjectIn = "ask" | "new" | "same";
export type OpenProjectIn = "ask" | "new" | "same" | "attach";
export type NotificationDisplay = "balloon" | "sticky" | "none";
/** Themes selectable as "preferred" when syncing with the OS. */
export type ConcreteTheme = BuiltinThemeId;
@@ -140,7 +140,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
const d = DEFAULT_GENERAL_EXTRAS;
const df = obj(g.dateFormat);
return {
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same"] as const, d.openProjectIn),
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle),
idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS),
+2 -1
Переглянути файл
@@ -281,6 +281,7 @@ function ToolWindowsGrid() {
const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
{ value: "new", label: "New window" },
{ value: "attach", label: "Attach" },
{ value: "same", label: "Current window" },
{ value: "ask", label: "Ask" },
];
@@ -566,7 +567,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Open project in",
keywords: ["New window", "Current window", "Ask"],
keywords: ["New window", "Current window", "Attach", "Ask"],
component: OpenProjectInRow,
},
{ kind: "custom", label: "Default directory", component: DefaultDirectoryRow },
+2
Переглянути файл
@@ -186,6 +186,8 @@ export const intellijMacos: Record<string, string[]> = {
"editor.nextSplitter": ["Alt+Tab"],
"editor.prevSplitter": ["Alt+Shift+Tab"],
"window.toggleFullScreen": ["Meta+Ctrl+F"],
"window.nextTab": ["Meta+Alt+PageDown"],
"window.previousTab": ["Meta+Alt+PageUp"],
"window.next": ["Meta+Alt+`"],
"window.previous": ["Meta+Alt+Shift+`"],
"file.reloadFromDisk": ["Meta+Alt+Y"],
+2
Переглянути файл
@@ -187,6 +187,8 @@ export const intellijWindows: Record<string, string[]> = {
"editor.nextSplitter": ["Alt+Tab"],
"editor.prevSplitter": ["Alt+Shift+Tab"],
"window.toggleFullScreen": [],
"window.nextTab": ["Ctrl+Alt+PageDown"],
"window.previousTab": ["Ctrl+Alt+PageUp"],
"window.next": ["Ctrl+Alt+]"],
"window.previous": ["Ctrl+Alt+["],
"file.reloadFromDisk": ["Ctrl+Alt+Y"],
+2 -1
Переглянути файл
@@ -77,8 +77,9 @@
"app.window.pickFileTitle": "Open File",
"app.window.pickTitle": "Open in New Window",
"app.window.askTitle": "Open Project",
"app.window.askBody": "Projects can either be opened in a new window or replace the project in the current window.\nHow would you like to open \"{name}\"?",
"app.window.askBody": "Projects can be opened in a new window, replace the current project, or be attached to the already opened projects.\nHow would you like to open \"{name}\"?",
"app.window.askThis": "This Window",
"app.window.askAttach": "Attach",
"app.window.askNew": "New Window",
"app.window.askRemember": "Don't ask again",
"common.cancel": "Cancel",
+28
Переглянути файл
@@ -1,4 +1,6 @@
{
"Attach": "附加",
"Remove from Workspace": "从工作区移除",
"Downloading Java {0} language server runtime": "正在下载 Java {0} 语言服务器运行时",
"auto-managed; uses an installed JDK or downloads a runtime when needed": "自动管理;使用已安装的 JDK,或在需要时自动下载运行时",
"Runs jdtls only; the project keeps its Project JDK. When empty, a suitable installed JDK is used first, otherwise Eclipse Temurin is downloaded automatically into a separate language-server folder. The jdtls executable itself is set in Language Servers.": "仅用于运行 jdtls,项目仍使用项目 JDK。留空时优先使用符合要求的已安装 JDK,否则自动将 Eclipse Temurin 下载到独立的语言服务器目录。jdtls 可执行文件在语言服务器设置中配置。",
@@ -4919,6 +4921,32 @@
"Unarchive Agent Tasks": "取消归档 Agent 任务",
"Cannot unarchive agent tasks": "无法取消归档 Agent 任务",
"Unarchive ({archived})": "取消归档({archived})",
"New Workspace…": "新建工作区…",
"New Workspace": "新建工作区",
"Open Workspace…": "打开工作区…",
"Open Workspace": "打开工作区",
"Manage Workspace…": "管理工作区…",
"Manage Workspace": "管理工作区",
"Merge All Project Windows": "合并所有项目窗口",
"Move Tab to New Window": "将标签页移到新窗口",
"Next Project Tab": "下一个项目标签页",
"Previous Project Tab": "上一个项目标签页",
"Project windows": "项目窗口",
"Linked projects": "关联项目",
"Workspace name": "工作区名称",
"Workspace location": "工作区位置",
"Workspace Location": "工作区位置",
"Choose workspace location": "选择工作区位置",
"Add Projects": "添加项目",
"Add Projects…": "添加项目…",
"Project folder path": "项目文件夹路径",
"Add the projects you want to work on together.": "添加需要一起工作的项目。",
"Project folders stay in place. Removing a project here keeps its files.": "项目文件夹保留在原位置。从此处移除项目不会删除文件。",
"Window group": "窗口组",
"Select Project Tab": "选择项目标签页",
"Close Project Tab": "关闭项目标签页",
"Reorder Project Tabs": "重新排列项目标签页",
"Close {name}": "关闭 {name}",
"Tomcat Server": "Tomcat 服务器",
"Run a WAR application in a local Tomcat server": "在本地 Tomcat 服务器中运行 WAR 应用",
"Tomcat installation directory": "Tomcat 安装目录",
+2 -1
Переглянути файл
@@ -77,8 +77,9 @@
"app.window.pickFileTitle": "打开文件",
"app.window.pickTitle": "在新窗口中打开",
"app.window.askTitle": "打开项目",
"app.window.askBody": "项目可以在新窗口中打开,也可以替换当前窗口中的项目。\n要如何打开“{name}”?",
"app.window.askBody": "项目可以在新窗口中打开、替换当前项目,或附加到已打开的项目。\n要如何打开“{name}”?",
"app.window.askThis": "当前窗口",
"app.window.askAttach": "附加",
"app.window.askNew": "新窗口",
"app.window.askRemember": "不再询问",
"common.cancel": "取消",
+10
Переглянути файл
@@ -98,3 +98,13 @@ export function dirname(path: string): string {
const i = path.lastIndexOf("/");
return i <= 0 ? "/" : path.slice(0, i);
}
/** The user-visible folder containing a file; workspace state is never a content root. */
export function projectFolder(project: ProjectInfo | null, path: string): { root: string; name: string } | null {
if (!project) return null;
if (!project.workspace) return project;
const root = project.workspace.projects
.filter((r) => path === r || path.startsWith(r + "/"))
.sort((a, b) => b.length - a.length)[0];
return root ? { root, name: basename(root) } : null;
}
+24
Переглянути файл
@@ -113,3 +113,27 @@ describe("project roots store", () => {
expect(currentRoots().map((r) => r.path)).toEqual(["out"]);
});
});
it("lists workspace members as scopes and admits their files without admitting workspace state", async () => {
const { renderHook } = await import("@testing-library/react");
const { useProjectModules } = await import("./projectRoots");
useProject.setState({
project: {
root: "/workspace",
name: "Workspace",
settings: {},
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/z", "/a"] },
},
settings: {} as never,
});
const hook = renderHook(() => useProjectModules());
expect(hook.result.current).toEqual([
{ name: "z", path: "/z" },
{ name: "a", path: "/a" },
]);
expect(inFileSet("all", "/z/src/main.ts")).toBe(true);
expect(inFileSet("all", "/workspace/.openidea/workspace.json")).toBe(false);
hook.unmount();
useProject.setState({ project: null });
});
+11 -5
Переглянути файл
@@ -15,7 +15,7 @@ import {
validateProjectStructure,
type RootFolder,
} from "./languageSettings";
import { useProject } from "./project";
import { basename, projectFolder, useProject } from "./project";
interface RootsState {
/** Project the roots belong to. */
@@ -108,7 +108,7 @@ const EMPTY: RootFolder[] = [];
/** Project-relative path of an absolute `path`, or null outside the project. */
export function projectRelative(path: string): string | null {
const root = useProject.getState().project?.root;
const root = projectFolder(useProject.getState().project, path)?.root;
if (!root) return null;
if (path === root) return "";
return path.startsWith(root + "/") ? path.slice(root.length + 1) : null;
@@ -142,7 +142,7 @@ export function inFileSet(set: ProjectFileSet, path: string, roots: readonly Roo
/** A module: the project itself or a folder with a build file roots were detected from. */
export interface ProjectModule {
name: string;
/** Project-relative, `/`-separated; `""` is the project root. */
/** Project-relative, or absolute for a workspace member; `""` is a plain project root. */
path: string;
}
@@ -162,6 +162,12 @@ export function projectModules(detected: readonly RootFolder[], projectName: str
/** Modules of the open project (reactive). */
export function useProjectModules(): ProjectModule[] {
const detected = useDetectedRoots();
const name = useProject((s) => s.project?.name ?? "");
return useMemo(() => projectModules(detected, name), [detected, name]);
const project = useProject((s) => s.project);
return useMemo(
() =>
project?.workspace
? project.workspace.projects.map((path) => ({ path, name: basename(path) }))
: projectModules(detected, project?.name ?? ""),
[detected, project],
);
}
+16
Переглянути файл
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { reorderedTabs } from "./windowGroups";
describe("project window order", () => {
const tabs = ["a", "b", "c"].map((label) => ({ label, name: label, current: label === "b", root: `/${label}` }));
it("moves a tab across its neighbours without changing the source or active window", () => {
expect(reorderedTabs(tabs, "a", "c")).toEqual(["b", "c", "a"]);
expect(reorderedTabs(tabs, "c", "a")).toEqual(["c", "a", "b"]);
expect(tabs.map((t) => t.label)).toEqual(["a", "b", "c"]);
expect(tabs.find((t) => t.current)?.label).toBe("b");
});
it("ignores external drags and missing targets", () => {
expect(reorderedTabs(tabs, "outside", "b")).toEqual(["a", "b", "c"]);
expect(reorderedTabs(tabs, "a", "outside")).toEqual(["a", "b", "c"]);
});
});
+55
Переглянути файл
@@ -0,0 +1,55 @@
import { create } from "zustand";
import { call, commands, isTauri, onIdeEvent, type WindowTab } from "./ipc";
import { startupLaunchHandled, useProject } from "./project";
import { setContext } from "./context";
export const useWindowGroup = create<{ tabs: WindowTab[] }>(() => ({ tabs: [] }));
export function reorderedTabs(tabs: readonly WindowTab[], source: string, target: string): string[] {
const labels = tabs.map((tab) => tab.label);
const from = labels.indexOf(source);
const to = labels.indexOf(target);
if (from < 0 || to < 0 || from === to) return labels;
labels.splice(to, 0, labels.splice(from, 1)[0]!);
return labels;
}
export async function refreshWindowGroup() {
if (!isTauri()) return;
useWindowGroup.setState({ tabs: await call(commands.windowGroupCurrent()) });
setContext("windowGrouped", useWindowGroup.getState().tabs.length > 1);
}
export async function selectWindowTab(label: string) {
await call(commands.windowGroupSelect(label));
}
export async function cycleWindowTab(forward: boolean) {
const tabs = useWindowGroup.getState().tabs;
if (tabs.length < 2) return;
const index = tabs.findIndex((tab) => tab.current);
await selectWindowTab(tabs[(index + (forward ? 1 : -1) + tabs.length) % tabs.length]!.label);
}
export function installWindowGroups(): () => void {
if (!isTauri()) return () => {};
let disposed = false;
const refresh = () => void refreshWindowGroup().catch(console.warn);
const restore = async () => {
await startupLaunchHandled();
if (disposed || !useProject.getState().project) return;
await call(commands.windowGroupRestore());
if (!disposed) await refreshWindowGroup();
};
const offEvent = onIdeEvent("windowGroupsChanged", refresh);
const offProject = useProject.subscribe((state, prev) => {
if (state.project?.root !== prev.project?.root) void restore().catch(console.warn);
});
refresh();
void restore().catch(console.warn);
return () => {
disposed = true;
offEvent();
offProject();
};
}
+62
Переглянути файл
@@ -0,0 +1,62 @@
.bar {
display: flex;
flex: none;
align-items: center;
gap: var(--island-gap);
padding: 0 var(--island-gap) var(--island-gap);
min-width: 0;
}
.tabs {
display: flex;
flex: 1;
gap: 4px;
overflow-x: auto;
min-width: 0;
}
.item {
display: flex;
align-items: center;
flex: 0 1 220px;
min-width: 100px;
border-radius: var(--radius-small);
color: var(--fg-muted);
}
.item:hover {
background: var(--bg-hover);
}
.item {
touch-action: none;
user-select: none;
}
.dragging {
opacity: 0.55;
}
.selected {
background: var(--bg-island);
color: var(--fg);
box-shadow: inset 0 -2px var(--accent);
}
.tab {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 0;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
}
.tab span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-radius: var(--radius-small);
}
+39
Переглянути файл
@@ -0,0 +1,39 @@
import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { useWindowGroup } from "@/platform/windowGroups";
import { WindowGroupTabs } from "./WindowGroupTabs";
vi.mock("@/platform/actions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/actions")>()),
executeAction: vi.fn(async () => {}),
}));
beforeEach(() => {
vi.mocked(executeAction).mockClear();
useWindowGroup.setState({
tabs: [
{ label: "main", name: "Frontend", root: "/front", current: true },
{ label: "project-1", name: "Backend", root: "/back", current: false },
],
});
});
it("switches and closes the intended project with mouse and keyboard", () => {
render(<WindowGroupTabs />);
const first = screen.getByRole("tab", { name: "Frontend" });
expect(first).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tab", { name: "Backend" })).toHaveAttribute("tabindex", "-1");
fireEvent.keyDown(first, { key: "ArrowLeft" });
expect(executeAction).toHaveBeenLastCalledWith("window.selectTab", "project-1");
fireEvent.click(screen.getByRole("button", { name: "Close Backend" }));
expect(executeAction).toHaveBeenLastCalledWith("window.closeTab", "project-1");
fireEvent.click(first);
expect(executeAction).toHaveBeenLastCalledWith("window.selectTab", "main");
});
it("removes the tab row when the group dissolves", () => {
useWindowGroup.setState({ tabs: [] });
render(<WindowGroupTabs />);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
+109
Переглянути файл
@@ -0,0 +1,109 @@
import { useRef, useState } from "react";
import { executeAction } from "@/platform/actions";
import { useTx } from "@/platform/i18n";
import { reorderedTabs, useWindowGroup } from "@/platform/windowGroups";
import { Icon, IconButton, showContextMenu } from "@/ui";
import { notifications } from "./contributions";
import styles from "./WindowGroupTabs.module.css";
function run(work: Promise<unknown>) {
void work.catch((e) => notifications().notify({ level: "error", title: "Window group", body: String(e) }));
}
export function WindowGroupTabs() {
const tabs = useWindowGroup((state) => state.tabs);
const drag = useRef<{ label: string; x: number; moved: boolean } | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const tx = useTx();
if (tabs.length < 2) return null;
return (
<div className={styles.bar} data-window-group>
<div className={styles.tabs} role="tablist" aria-label={tx("Project windows")}>
{tabs.map((tab) => (
<div
className={`${styles.item} ${tab.current ? styles.selected : ""} ${dragging === tab.label ? styles.dragging : ""}`}
key={tab.label}
data-project-tab={tab.label}
onPointerDown={(e) => {
drag.current = null;
if (e.button === 0 && (e.target as Element).closest('[role="tab"]'))
drag.current = { label: tab.label, x: e.clientX, moved: false };
}}
onPointerMove={(e) => {
if (drag.current && Math.abs(e.clientX - drag.current.x) > 5) {
drag.current.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(tab.label);
}
}}
onPointerUp={(e) => {
const source = drag.current;
if (source?.moved) {
const target = document
.elementFromPoint(e.clientX, e.clientY)
?.closest<HTMLElement>("[data-project-tab]")?.dataset.projectTab;
if (target) run(executeAction("window.reorderTabs", reorderedTabs(tabs, source.label, target)));
}
setDragging(null);
// Keep the moved flag through the click following pointerup.
if (!source?.moved) drag.current = null;
}}
onPointerCancel={() => {
drag.current = null;
setDragging(null);
}}
onClickCapture={(e) => {
if (drag.current?.moved) {
e.preventDefault();
e.stopPropagation();
drag.current = null;
}
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, [
{
label: "Move Tab to New Window",
disabled: !tab.current,
run: () => executeAction("window.detachTab"),
},
{ label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) },
]);
}}
>
<button
className={styles.tab}
role="tab"
aria-selected={tab.current}
tabIndex={tab.current ? 0 : -1}
title={tab.root ?? tab.name}
onClick={() => run(executeAction("window.selectTab", tab.label))}
onKeyDown={(e) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
e.preventDefault();
const index = tabs.indexOf(tab);
const next =
e.key === "Home"
? 0
: e.key === "End"
? tabs.length - 1
: (index + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
run(executeAction("window.selectTab", tabs[next]!.label));
}}
>
<Icon name="folder" size={14} />
<span>{tab.name}</span>
</button>
<IconButton
icon="close"
size="small"
title={tx`Close ${tab.name}`}
onClick={() => run(executeAction("window.closeTab", tab.label))}
/>
</div>
))}
</div>
<IconButton icon="external-link" title="Move Tab to New Window" action="window.detachTab" />
</div>
);
}
+2
Переглянути файл
@@ -12,6 +12,7 @@ import { OverlayHost } from "./OverlayHost";
import { projectColorVars } from "./projectColor";
import { StatusBar } from "./StatusBar";
import { TitleBar } from "./TitleBar";
import { WindowGroupTabs } from "./WindowGroupTabs";
import { useApplyTheme } from "./theme";
import { ToolWindowLayout } from "./toolwindows/ToolWindows";
import { Welcome } from "./Welcome";
@@ -52,6 +53,7 @@ export function Workbench() {
<>
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
<TitleBar bare={distractionFree} />
{!distractionFree && <WindowGroupTabs />}
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
+1
Переглянути файл
@@ -43,6 +43,7 @@ export function activateWorkbench(): () => void {
// Synchronous so the frame's first render after opening already has the project's layout.
const onProject = (root: string | null) => {
setContext("projectOpen", root !== null);
setContext("workspaceOpen", !!useProject.getState().project?.workspace);
setContext("lightEdit", useProject.getState().lightEdit);
if (root !== null) loadToolWindowLayout(root);
};
+2
Переглянути файл
@@ -497,6 +497,8 @@ export interface EditorService {
activeCursor(): { doc: number; path: string; line: number; col: number; offset: number } | null;
/** Save all dirty documents; rejects with the failures once every save was tried. */
saveAll(): Promise<void>;
/** Includes local edits not yet acknowledged by the backend. */
dirtyDocumentIds?(): number[];
/** Move keyboard focus to the active editor (used by Escape from tool windows). */
focus?(): void;
/** Paths of the documents open in editor tabs (all panes, tab order). */
+46
Переглянути файл
@@ -0,0 +1,46 @@
import { beforeEach, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { chooseProjectWindow, openPolicy } from "./projectWindow";
import { ask } from "./messages";
import { commands } from "@/platform/ipc";
vi.mock("./messages", () => ({ ask: vi.fn() }));
vi.mock("@/platform/remote", () => ({
isRemoteWindow: () => false,
connectRemote: vi.fn(),
}));
vi.mock("@/platform/ipc", () => ({
call: (p: unknown) => p,
commands: {
settingsSet: vi.fn(async () => ({ general: { openProjectIn: "attach" } })),
},
}));
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({
project: { root: "/a", name: "A", settings: {}, isGit: false },
settings: {},
lightEdit: false,
});
});
it("offers IDEA's four choices with This Window primary and remembers Attach", async () => {
vi.mocked(ask).mockResolvedValue({ button: "attach", checked: true });
expect(await chooseProjectWindow("B")).toBe("attach");
const prompt = vi.mocked(ask).mock.calls[0]![0];
expect(prompt.buttons.map((b) => b.id)).toEqual(["cancel", "attach", "new", "same"]);
expect(prompt.buttons.filter((b) => b.primary).map((b) => b.id)).toEqual(["same"]);
expect(commands.settingsSet).toHaveBeenCalledWith("global", "general.openProjectIn", '"attach"');
expect(openPolicy()).toBe("attach");
expect(await chooseProjectWindow("C")).toBe("attach");
expect(ask).toHaveBeenCalledTimes(1);
});
it("does not attach without a project or when opening a remote project", async () => {
useProject.setState({
settings: { general: { openProjectIn: "attach" } } as never,
});
vi.mocked(ask).mockResolvedValue({ button: "cancel", checked: false });
expect(await chooseProjectWindow("remote", false)).toBeNull();
expect(vi.mocked(ask).mock.calls[0]![0].buttons.map((b) => b.id)).not.toContain("attach");
useProject.setState({ project: null });
expect(await chooseProjectWindow("B")).toBe("same");
});
+13 -9
Переглянути файл
@@ -2,14 +2,14 @@
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { confirmLeaveProject, useProject } from "@/platform/project";
import { connectRemote } from "@/platform/remote";
import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { ask } from "./messages";
import { maybeEditorService } from "./contributions";
/** "Open project in": `new` | `same` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" {
/** "Open project in": `new` | `same` | `attach` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" | "attach" {
const v = useProject.getState().settings?.general?.openProjectIn;
return v === "new" || v === "same" ? v : "ask";
return v === "new" || v === "same" || v === "attach" ? v : "ask";
}
/**
@@ -17,22 +17,26 @@ export function openPolicy(): "ask" | "new" | "same" {
* per "Open project in", asking when set to (and remembering "Don't ask again").
* `null`: cancelled.
*/
export async function chooseProjectWindow(name: string): Promise<"new" | "same" | null> {
export async function chooseProjectWindow(name: string, allowAttach = true): Promise<"new" | "same" | "attach" | null> {
const { project, lightEdit } = useProject.getState();
if (!project && !lightEdit) return "same";
const mode = openPolicy();
const canAttach = allowAttach && !!project && !isRemoteWindow();
const policy = openPolicy();
const mode = policy === "attach" && !canAttach ? "ask" : policy;
if (mode !== "ask") return mode;
const r = await ask({
title: t("app.window.askTitle"),
width: canAttach ? 640 : undefined,
body: t("app.window.askBody", { name }),
buttons: [
{ id: "cancel", label: t("common.cancel") },
...(canAttach ? [{ id: "attach", label: t("app.window.askAttach") }] : []),
{ id: "new", label: t("app.window.askNew") },
{ id: "same", label: t("app.window.askThis"), primary: true },
{ id: "cancel", label: t("common.cancel") },
],
checkbox: t("app.window.askRemember"),
});
if (r.button !== "same" && r.button !== "new") return null;
if (r.button !== "same" && r.button !== "new" && r.button !== "attach") return null;
if (r.checked) {
try {
useProject.setState({
@@ -56,7 +60,7 @@ export async function openRemoteProject(
here: () => Promise<unknown> = () => connectRemote(sshConfig, path),
): Promise<boolean> {
if ((await call(commands.windowOpenRemote(sshConfig, path, false))).kind === "focused") return true;
const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path);
const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path, false);
if (!mode) return false;
if (mode === "new") {
await call(commands.windowOpenRemote(sshConfig, path, true));