feat(workbench): Edit single files without opening a project (#145)

Files can now be opened without a project. A file dropped on the Welcome screen, picked with File › Open File…, or passed on the command line or to a second instance opens in a lightweight window: editor tabs only, with no project tree, indexing, language servers, VCS or project tool windows. Syntax highlighting, find, save, undo and Local History still work. More files opened later join the same window as new tabs. Open as Project in the status bar opens the file's directory as a full project, following the open-project policy, and reopens the file there.

Backend: a light session reuses Project with an unindexed index, no LSP or VCS, and a flat watch on each opened file's directory. It is never reused as a project for the same root. Previously a file passed at launch opened its parent directory as a project.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 22:06:35 +08:00
1 джерело eb67595e72
коміт 2d7726cf53
40 files changed
+1042 -140

No files matched your search

+26
Переглянути файл
@@ -0,0 +1,26 @@
/** LightEdit status bar entry: opens the current file's folder as a full project. */
import { useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { useT } from "@/platform/i18n";
import { dirname, useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { BarButton, Icon } from "@/ui";
export const LIGHT_EDIT_OPEN_PROJECT = "lightEdit.openProject";
export function LightEditStatus() {
const t = useT();
const lightEdit = useProject((s) => s.lightEdit);
const path = useEditorRegistry((s) => s.active?.ctx.path);
if (!lightEdit || !path || isVirtualUri(path)) return null;
return (
<BarButton
emphasis
tooltip={t("app.lightEdit.openProjectTooltip", { folder: dirname(path) })}
onClick={() => void executeAction(LIGHT_EDIT_OPEN_PROJECT)}
>
<Icon name="folder-open" size={14} />
{t("app.lightEdit.openProject")}
</BarButton>
);
}
+158 -13
Переглянути файл
@@ -1,8 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const windowOpenProject = vi.fn();
const windowOpenFiles = vi.fn();
const windowTakeLaunch = vi.fn();
const openProject = vi.fn(async (path: string) => ({ root: path, name: path, isGit: false, settings: {} }));
// The backend answers with the global settings, here: whatever the test set up.
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] }));
const openProject = vi.fn(async (path: string) => {
const project = { root: path, name: path, isGit: false, settings: {} };
useProject.setState({ project, lightEdit: false } as never);
return project;
});
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
@@ -12,7 +20,10 @@ vi.mock("@/platform/ipc", async (orig) => {
commands: {
...real.commands,
windowOpenProject: (...a: unknown[]) => windowOpenProject(...a),
windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a),
windowTakeLaunch: () => windowTakeLaunch(),
lightEditOpen: (p: string) => lightEditOpen(p),
fsReadDir: (p: string) => fsReadDir(p),
},
};
});
@@ -21,45 +32,179 @@ vi.mock("@/platform/project", async (orig) => {
return { ...real, openProject: (p: string) => openProject(p) };
});
const { openProjectInWindow, consumeLaunch } = await import("../windows");
const { openProjectInWindow, consumeLaunch, openFiles, openPaths, openLightEditFileAsProject, windowActions } =
await import("../windows");
const { useProject } = await import("@/platform/project");
const { provideEditorService } = await import("@/workbench/contributions");
const opened: string[] = [];
let activePath: string | null = null;
provideEditorService({
openFile: async (path, options) => void opened.push(`${path}${options?.focus ? " (focus)" : ""}`),
openInput: async () => {},
activePath: () => activePath,
activeCursor: () => null,
saveAll: async () => {},
});
const ok = (data: unknown) => ({ status: "ok", data });
const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } });
beforeEach(() => {
windowOpenProject.mockReset();
windowTakeLaunch.mockReset();
openProject.mockClear();
useProject.setState({ project: null });
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
opened.length = 0;
activePath = null;
useProject.setState({ project: null, lightEdit: false, settings: null } as never);
});
describe("openProjectInWindow", () => {
it("opens in this window when the backend says `current`", async () => {
windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "current" } });
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", false, []);
expect(openProject).toHaveBeenCalledWith("/p/a");
});
it("asks for a new window when this one already has a project", async () => {
useProject.setState({ project: { root: "/p/b", name: "b", isGit: false, settings: {} } });
windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "opened", label: "project-1" } });
useProject.setState(projectState("/p/b") as never);
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []);
expect(openProject).not.toHaveBeenCalled();
});
it("asks for a new window from a LightEdit window", async () => {
useProject.setState({ lightEdit: true });
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openProjectInWindow("/p/a", false);
expect(windowOpenProject).toHaveBeenCalledWith("/p/a", true, []);
});
it("does nothing locally when another window was focused", async () => {
windowOpenProject.mockResolvedValue({ status: "ok", data: { kind: "focused", label: "main" } });
windowOpenProject.mockResolvedValue(ok({ kind: "focused", label: "main" }));
await openProjectInWindow("/p/a", true);
expect(openProject).not.toHaveBeenCalled();
});
});
describe("loose files (LightEdit)", () => {
it("turns a window without a project into LightEdit when no LightEdit window exists", async () => {
windowOpenFiles.mockResolvedValue(ok({ kind: "current" }));
await openFiles(["/x/a.txt", "/y/b.md"]);
expect(windowOpenFiles).toHaveBeenCalledWith(["/x/a.txt", "/y/b.md"]);
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(useProject.getState()).toMatchObject({ project: null, lightEdit: true });
expect(opened).toEqual(["/x/a.txt", "/y/b.md (focus)"]);
});
it("leaves files to the existing LightEdit window", async () => {
windowOpenFiles.mockResolvedValue(ok({ kind: "focused", label: "project-2" }));
await openFiles(["/x/a.txt"]);
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual([]);
expect(useProject.getState().lightEdit).toBe(false);
});
it("adds tabs to this LightEdit window", async () => {
useProject.setState({ lightEdit: true });
await openFiles(["/x/c.txt"]);
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual(["/x/c.txt (focus)"]);
});
it("opens files in the project's editor in a project window", async () => {
useProject.setState(projectState("/p") as never);
await openFiles(["/elsewhere/a.txt"]);
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(opened).toEqual(["/elsewhere/a.txt (focus)"]);
expect(useProject.getState().lightEdit).toBe(false);
});
it("routes dropped files to LightEdit and the dropped folder to a project", async () => {
windowOpenFiles.mockResolvedValue(ok({ kind: "current" }));
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never);
await openPaths(["/x/a.txt", "/work/app"], async (p) => p === "/work/app");
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(windowOpenProject).toHaveBeenCalledWith("/work/app", true, []);
});
it("opens a dropped folder alone exactly as before", async () => {
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openPaths(["/work/app"], async () => true);
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(lightEditOpen).not.toHaveBeenCalled();
expect(openProject).toHaveBeenCalledWith("/work/app");
});
it("classifies paths through the file system by default", async () => {
fsReadDir.mockResolvedValueOnce({ status: "error", error: { kind: "notFound", message: "not a dir" } } as never);
windowOpenFiles.mockResolvedValue(ok({ kind: "current" }));
await openPaths(["/x/a.txt"]);
expect(fsReadDir).toHaveBeenCalledWith("/x/a.txt");
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
});
});
describe("Open Containing Folder as Project", () => {
it("is an action available in LightEdit only", () => {
const action = windowActions().find((a) => a.id === "lightEdit.openProject");
expect(action?.when).toBe("lightEdit");
expect(windowActions().some((a) => a.id === "file.openFile")).toBe(true);
});
it("opens the folder in a new window with the file, per 'Open project in'", async () => {
useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "new" } } } as never);
activePath = "/x/notes/a.txt";
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
await openLightEditFileAsProject();
expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", true, ["/x/notes/a.txt"]);
expect(openProject).not.toHaveBeenCalled();
});
it("or turns this window into the project and reopens the file there", async () => {
useProject.setState({ lightEdit: true, settings: { general: { openProjectIn: "same" } } } as never);
activePath = "/x/notes/a.txt";
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
await openLightEditFileAsProject();
expect(windowOpenProject).toHaveBeenCalledWith("/x/notes", false, ["/x/notes/a.txt"]);
expect(openProject).toHaveBeenCalledWith("/x/notes");
expect(useProject.getState()).toMatchObject({ lightEdit: false, project: { root: "/x/notes" } });
expect(opened).toEqual(["/x/notes/a.txt (focus)"]);
});
it("does nothing outside LightEdit", async () => {
useProject.setState(projectState("/p") as never);
activePath = "/p/a.txt";
await openLightEditFileAsProject();
expect(windowOpenProject).not.toHaveBeenCalled();
});
});
describe("launch", () => {
it("opens the pending project once", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch" }).mockResolvedValueOnce(null);
windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch", files: [] }).mockResolvedValueOnce(null);
await consumeLaunch();
await consumeLaunch();
expect(openProject).toHaveBeenCalledTimes(1);
expect(openProject).toHaveBeenCalledWith("/p/launch");
});
it("opens launched files in LightEdit without a project", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: null, files: ["/x/a.txt"] });
await consumeLaunch();
expect(windowOpenFiles).not.toHaveBeenCalled();
expect(lightEditOpen).toHaveBeenCalledWith("/x/a.txt");
expect(opened).toEqual(["/x/a.txt (focus)"]);
});
it("opens launched files in the launched project", async () => {
windowTakeLaunch.mockResolvedValueOnce({ project: "/x/notes", files: ["/x/notes/a.txt"] });
await consumeLaunch();
expect(openProject).toHaveBeenCalledWith("/x/notes");
expect(lightEditOpen).not.toHaveBeenCalled();
expect(opened).toEqual(["/x/notes/a.txt (focus)"]);
});
});
+4 -4
Переглянути файл
@@ -99,9 +99,9 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
try {
const ctx = await commands.windowCloseContext();
const s = useProject.getState();
// Closing the last project window closes its project and shows the welcome screen
// (to open another one); closing the welcome screen, or quitting, closes the window.
if (!ctx.quitting && ctx.windows <= 1 && s.project) {
// Closing the last project (or LightEdit) window closes its project and shows the welcome
// screen (to open another one); closing the welcome screen, or quitting, closes the window.
if (!ctx.quitting && ctx.windows <= 1 && (s.project || s.lightEdit)) {
if (await confirmLeave(deps)) await closeProject();
return false;
}
@@ -148,7 +148,7 @@ export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void {
else dirtyDocs.delete(e.doc);
}),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) dirtyDocs.clear();
if (s.project?.root !== prev.project?.root || s.lightEdit !== prev.lightEdit) dirtyDocs.clear();
}),
provideLeaveProjectGuard(() => confirmLeave(deps)),
];
+9 -1
Переглянути файл
@@ -2,13 +2,14 @@
* Desktop shell integration: native menu bridge, multi-window, window title,
* close / quit guards, About, Keymap Reference, logs, update check and i18n wiring.
*/
import { registerOverlay } from "@/workbench/contributions";
import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { AboutOverlay } from "./AboutDialog";
import { appActions, checkForUpdatesOnStartup, updateActions } from "./appActions";
import { installCloseGuard } from "./closeGuard";
import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches";
import { buildInfo } from "./host";
import { KeymapReferenceOverlay } from "./KeymapReference";
import { LightEditStatus } from "./LightEditStatus";
import { installLocaleSync, registerLocalizedActions } from "./locale";
import { installMenuBridge } from "./menuBridge";
import { NewProjectOverlay } from "./NewProject";
@@ -27,6 +28,13 @@ export function activate(): () => void {
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
registerOverlay({ id: "app.newProject", component: NewProjectOverlay }),
registerStatusItem({
id: "app.lightEdit",
align: "left",
priority: 20,
component: LightEditStatus,
lightEdit: true,
}),
registerLocalizedActions(projectActions),
registerPowerSaveBanner(),
registerInvalidateCachesActions(),
+152 -34
Переглянути файл
@@ -2,14 +2,26 @@
* Multi-window: one project per window. New windows are created by the
* backend (`window_open_project` / `window_new`) and told what to open via a
* pending launch, taken here on startup and on `openidea://launch`.
*
* Loose files opened without a project (dropped on the Welcome screen, File ›
* 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 { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project";
import { connectRemote } from "@/platform/remote";
import {
basename,
confirmLeaveProject,
dirname,
openLightEdit,
openProject,
provideStartupLaunch,
useProject,
} from "@/platform/project";
import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { notifications } from "@/workbench/contributions";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { chooseProjectWindow } from "@/workbench/projectWindow";
export const LAUNCH_EVENT = "openidea://launch";
@@ -24,59 +36,126 @@ function desktopOnly(): boolean {
return true;
}
async function openHere(path: string) {
if (useProject.getState().project?.root === path) return;
if (!(await confirmLeaveProject())) return;
function openFailed(path: string, e: unknown) {
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
body: errorText(e),
key: `app.open:${path}`,
});
}
/** Opens project `path` in this window; true once it is this window's project. */
async function openHere(path: string): Promise<boolean> {
if (useProject.getState().project?.root === path) return true;
if (!(await confirmLeaveProject())) return false;
try {
await openProject(path);
return true;
} catch (e) {
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
body: errorText(e),
key: `app.open:${path}`,
});
openFailed(path, e);
return false;
}
}
/** Opens `files` as editor tabs of this window, focusing the last. */
async function openInEditor(files: readonly string[]): Promise<void> {
for (const [i, path] of files.entries())
await editorService()
.openFile(path, { focus: i === files.length - 1 })
.catch((e) => console.warn(`could not open ${path}`, e));
}
/** Opens `files` in this window: in its project's editor or, without one, in LightEdit. */
async function openFilesHere(files: readonly string[]): Promise<void> {
const s = useProject.getState();
if (!s.project && !s.lightEdit) {
try {
await openLightEdit(files[0]!);
} catch (e) {
return openFailed(files[0]!, e);
}
}
await openInEditor(files);
}
/**
* Opens loose `files`: in this window's local project or LightEdit session, else
* in the LightEdit window if there is one, else this window turns into LightEdit
* (a remote project window gets a new one).
*/
export async function openFiles(files: readonly string[]): Promise<void> {
if (!files.length) return;
const s = useProject.getState();
if (s.lightEdit || (s.project && !isRemoteWindow())) return openInEditor(files);
if (desktopOnly()) return;
try {
const outcome = await call(commands.windowOpenFiles([...files]));
if (outcome.kind === "current") await openFilesHere(files);
} catch (e) {
openFailed(files[0]!, e);
}
}
const pathIsDir = (path: string) => call(commands.fsReadDir(path)).then(() => true);
/** Opens dropped paths: files as loose files ({@link openFiles}), the first folder as a project. */
export async function openPaths(
paths: readonly string[],
isDir: (path: string) => Promise<boolean> = pathIsDir,
): Promise<void> {
const kinds = await Promise.all(paths.map((p) => isDir(p).catch(() => false)));
const files = paths.filter((_, i) => !kinds[i]);
const dir = paths.find((_, i) => kinds[i]);
await openFiles(files);
if (dir) await openProjectWithPolicy(dir);
}
/**
* Opens `path`, focusing the window that already has it. `newWindow`: always a
* new window; otherwise this window unless it already has a project.
* new window; otherwise this window unless it already has a project or LightEdit
* session. `files` then open in the project's editor.
*/
export async function openProjectInWindow(path: string, newWindow: boolean): Promise<void> {
export async function openProjectInWindow(path: string, newWindow: boolean, files: readonly string[] = []) {
if (desktopOnly()) return;
const inNew = newWindow || useProject.getState().project !== null;
const s = useProject.getState();
const inNew = newWindow || s.project !== null || s.lightEdit;
try {
const outcome = await call(commands.windowOpenProject(path, inNew));
if (outcome.kind === "current") await openHere(path);
const outcome = await call(commands.windowOpenProject(path, inNew, [...files]));
if (outcome.kind === "current" && (await openHere(path))) await openInEditor(files);
} catch (e) {
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
body: errorText(e),
key: `app.open:${path}`,
});
openFailed(path, e);
}
}
/** Opens `path` following "Open project in" when this window already has a project. */
export async function openProjectWithPolicy(path: string): Promise<void> {
/**
* Opens `path` following "Open project in" when this window already has a
* project or LightEdit session, then `files` in its editor.
*/
export async function openProjectWithPolicy(path: string, files: readonly string[] = []): Promise<void> {
if (desktopOnly()) return;
const current = useProject.getState().project;
if (!current) return openProjectInWindow(path, false);
if (current.root === path) return;
const { project: current, lightEdit } = useProject.getState();
if (!current && !lightEdit) return openProjectInWindow(path, false, files);
if (current?.root === path) return openInEditor(files);
const mode = await chooseProjectWindow(basename(path));
if (!mode) return;
if (mode === "new") return openProjectInWindow(path, true);
if (mode === "new") return openProjectInWindow(path, true, files);
try {
// A window that already has the project is focused instead.
const outcome = await call(commands.windowOpenProject(path, false));
if (outcome.kind === "current") await openHere(path);
const outcome = await call(commands.windowOpenProject(path, false, [...files]));
if (outcome.kind === "current" && (await openHere(path))) await openInEditor(files);
} catch {
await openHere(path);
if (await openHere(path)) await openInEditor(files);
}
}
/** LightEdit: opens the current file's folder as a project ("Open project in" applies) and the file there. */
export async function openLightEditFileAsProject(): Promise<void> {
if (!useProject.getState().lightEdit) return;
const path = maybeEditorService()?.activePath();
if (path) await openProjectWithPolicy(dirname(path), [path]);
}
/** System Settings › Project › "Default directory" for folder pickers. */
export function defaultProjectDirectory(): string | undefined {
return useProject.getState().settings?.general?.defaultProjectDirectory?.trim() || undefined;
@@ -89,11 +168,20 @@ async function pickFolder(title: string): Promise<string | null> {
return path ?? null;
}
/** Takes this window's pending launch (if any) and opens its project. */
async function pickFiles(title: string): Promise<string[]> {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: false, multiple: true, title, defaultPath: defaultProjectDirectory() });
return picked === null ? [] : Array.isArray(picked) ? picked : [picked];
}
/** Takes this window's pending launch (if any) and opens its project and files. */
export async function consumeLaunch(): Promise<void> {
if (!isTauri()) return;
const launch = await commands.windowTakeLaunch().catch(() => null);
if (launch?.project) await openHere(launch.project);
const files = launch?.files ?? [];
if (launch?.project) {
if (await openHere(launch.project)) await openInEditor(files);
} else if (files.length) await openFilesHere(files);
else if (launch?.remote) {
const { sshConfig, path } = launch.remote;
await connectRemote(sshConfig, path).catch((e) =>
@@ -121,6 +209,36 @@ export function windowActions(): Action[] {
if (path) await openProjectInWindow(path, true);
},
},
{
id: "file.openFile",
title: t("app.action.openFile"),
category: t("app.category.file"),
icon: "file",
keywords: ["file", "light edit"],
run: async () => {
if (desktopOnly()) return;
await openFiles(await pickFiles(t("app.window.pickFileTitle")));
},
},
{
// Files and folders dropped on the Welcome screen.
id: "file.openPaths",
title: "Open Paths",
category: t("app.category.file"),
hidden: true,
run: async (c) => {
if (Array.isArray(c.arg)) await openPaths(c.arg.filter((p): p is string => typeof p === "string"));
},
},
{
id: "lightEdit.openProject",
title: t("app.action.lightEditOpenProject"),
category: t("app.category.file"),
icon: "folder-open",
keywords: ["light edit", "project", "folder"],
when: "lightEdit",
run: openLightEditFileAsProject,
},
{
id: "file.openRecentProject",
title: t("app.action.openRecentProject"),
+8 -1
Переглянути файл
@@ -77,7 +77,14 @@ export function activate(): () => void {
syncVimSettingsFromStorage(),
registerKeyClaim(vimKeyClaim(() => useVimSettings.getState())),
activateSettingsPages(),
registerStatusItem({ id: "vim.mode", align: "right", priority: 45, component: VimStatus, title: "Vim" }),
registerStatusItem({
id: "vim.mode",
align: "right",
priority: 45,
component: VimStatus,
title: "Vim",
lightEdit: true,
}),
registerActions([
{
id: "vim.toggle",
+1 -1
Переглянути файл
@@ -72,7 +72,7 @@ export function ProjectPopup({ anchor, onClose }: ProjectPopupProps) {
if (!e) return;
onClose();
if (e.kind === "action") void executeAction(e.id);
else if (e.open) void call(commands.windowOpenProject(e.m.project.path, false)).catch(() => undefined);
else if (e.open) void call(commands.windowOpenProject(e.m.project.path, false, [])).catch(() => undefined);
else void openRecent(e.m.project);
};
+8 -4
Переглянути файл
@@ -84,7 +84,10 @@ function NavItem({
);
}
/** Folders dropped from the OS open as a project (Tauri drag-drop events carry real paths). */
/**
* Paths dropped from the OS (Tauri drag-drop events carry real paths): a folder opens
* as a project, files open in LightEdit.
*/
function useFolderDrop(): boolean {
const [over, setOver] = useState(false);
useEffect(() => {
@@ -97,8 +100,9 @@ function useFolderDrop(): boolean {
const p = event.payload;
if (p.type === "enter" || p.type === "over") return setOver(true);
setOver(false);
if (p.type === "drop" && p.paths[0] && !useProject.getState().project)
void executeAction("file.openProjectPath", p.paths[0]);
const { project, lightEdit } = useProject.getState();
if (p.type === "drop" && p.paths.length && !project && !lightEdit)
void executeAction("file.openPaths", p.paths);
})
.then((u) => {
if (disposed) u();
@@ -194,7 +198,7 @@ export function WelcomeView() {
{dropping && (
<div className={styles.dropOverlay} aria-hidden>
<Icon name="folder-open" size={20} />
<T>Drop a folder to open it as a project</T>
<T>Drop a folder to open it as a project, or files to edit them</T>
</div>
)}
</section>