дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,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)}` : ""}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ?? (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rootLabel {
|
||||
.rootName {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
}
|
||||
.rootLabel {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 安装目录",
|
||||
|
||||
@@ -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": "取消",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Заблокувати користувача