diff --git a/CHANGELOG.md b/CHANGELOG.md
index b634431b..f669907c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
+### Added
+
+- **Edit overrides in VS Code.** **Open in VS Code**, on an override's tab, its right-click menu in the Explorer or the command palette, opens its file there. Save it in VS Code, or any other editor, and the page reloads with your version, just as if you had saved in the app. A tab left open shows the new text; if it has edits you haven't saved, it keeps them and offers the saved file's version instead. **Show in folder** finds the file for any other editor.
+
## [0.4.0] - 2026-09-26
Console Editor now shows how a site works, not just what it loaded. Pick any element to see the React, Vue, Angular or web component behind it, traced to its source file even in a production build, and record why components rendered and what the page's stores did. A new Network panel overrides API responses and pauses requests, rules block requests and change headers, and actions run code in any frame with one click.
diff --git a/README.md b/README.md
index f1d67faa..883ff194 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ Each workspace keeps its own page, tabs, overrides, rules and actions, so a fix
- **Edit what the page actually loaded.** Scripts, stylesheets and HTML appear under **Page resources** as they load, grouped by origin, iframes and workers included. Minified bundles are pretty-printed when you open them.
- **Save to serve it.** Ctrl/⌘ + S reloads the page with your version, before the page runs anything, on every load, until you switch the override off.
+- **Or edit in VS Code.** **Open in VS Code** opens an override's file there: what you save is served, and the page reloads with it.
- **Survives deploys.** A hashed bundle name such as `main.3f9a1c2b.js` can be matched as `main.*.js` in one click, and you're warned when the live file changes under your override. **Diff** (Ctrl/⌘ + Shift + D) shows what you changed.
- **Handles the hard cases:** compressed responses, Subresource Integrity (static and set at runtime), the HTTP cache, service workers and worklets, stale source maps.
- **Reads the original sources.** When the site publishes source maps, expand a bundle to open the TypeScript, JSX or SCSS it was built from, and jump between a line of it and the bundle code it became (Ctrl/⌘ + Shift + M).
diff --git a/docs/SPEC.md b/docs/SPEC.md
index a0414c32..f0902ce6 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -27,7 +27,7 @@ A desktop app where you enter a website's URL, see every script, stylesheet and
| U8 | I'm told when the live file changed since I created the override, and can diff against the new live version | ✅ |
| U9 | SRI, gzip, caching and service workers don't silently defeat my edits | ✅ |
| U10 | I can open DevTools for the page to use the console, breakpoints and network panel | ✅ |
-| U11 | I can edit override files in my own editor (VS Code) and the app picks up changes | 🔜 M2 |
+| U11 | I can edit override files in my own editor (VS Code) and the app picks up changes | ✅ (§7, Another editor) |
| U12 | Overrides and file listing inside iframes, including cross-site (out-of-process) and nested ones | ✅ |
| U12b | Overrides and file listing for what workers load: dedicated, shared and service workers, and worklets | ✅ (except a nested worker's first script, §6.6) |
| U13 | Export/import a workspace's overrides to share them with teammates | 🔜 M2 |
@@ -338,6 +338,8 @@ session/drafts/.base.txt what that tab's editing started from (tabs not y
A version 1 `session.json` (one page and its tabs) becomes the first workspace, and overrides and rules saved before workspaces existed (or whose workspace is gone) are given to the active one at start.
+**Edits made elsewhere.** `OverrideFileWatcher` (`src/main/overrideFiles/`) watches `files/` (`fs.watch`, not persistent). Only content files count (`.`: not a base, nor a temp file such as `writeAtomic`'s or an editor's swap file). Once the folder has been quiet for 200 ms (`EDIT_SETTLE_MS`: a save can be several writes, a checkout many files), each changed override's file is read in the store's write queue (`takeFileEdit`), after any write of the app's own, so the app's writes read back unchanged. A file that differs becomes the override's content: in memory, so the engine serves it at once, and in the index, with a new `updatedAt`. A missing file (an editor renaming its save over it) is taken once it is back; until then the override keeps serving what it had. When the platform names no file, the active workspace's overrides are all checked. The active workspace's changed overrides then go to the renderer, in one batch, with `overrides-changed` and then `overrides-edited`. Another workspace's are served as edited once it is active again.
+
**Rules on disk.** `RuleStore` writes a change to disk before it reaches memory (and so the engine); a failed write leaves the rules as they were. Entries this build can't read (a newer version's action or request type, a hand edit) are neither listed nor applied, and are written back as they were. A `rules.json` that isn't JSON of the right shape is moved to `rules.json.broken` and the app starts with no rules; one that can't be read, or moved aside, is left alone and every change is refused, so it is never written over. Either way the window says so once it opens.
**Session restore.** The main process remembers the page URL on every main-frame navigation, for the active workspace. The renderer writes the tab list 300 ms after it changes and a tab's draft 800 ms after typing pauses (the base once per tab); a draft is deleted when its tab is saved, undone back to the saved text, or closed. Closing the window runs a handshake: main sends `flush-session`, the renderer writes whatever is pending and answers `sessionFlushed(ok)`, and only then does the window close (after 5 s, or if a write failed, it asks before closing). On start, the app loads the last URL (a URL on the command line wins), then reopens each tab: an override from the store, a tab with a draft entirely from disk (no network), any other tab by fetching the file again; drafts are applied as one undoable edit, so the tab shows as unsaved and undo reveals the saved text. Tab ids are unique across runs because they name the drafts. Session syncing starts only after restoring, so a fresh start never overwrites the session being restored. Each sync run is bound to one workspace: tab lists are written with its id, and one that arrives for a deleted workspace is ignored.
@@ -675,6 +677,7 @@ While store actions are recorded, every action the page's stores handle in every
| Pretty-print (Shift+Alt+F) | js-beautify in a Web Worker; one undoable edit |
| Diff (Ctrl/Cmd+Shift+D) | Monaco diff: base (left, read-only) vs. current (right, editable) |
| Compare live | Fetch today's live file (pretty-printed if minified) and diff it against the override |
+| Another editor | **Open in VS Code** (an override tab's header, its Explorer row's context menu, the palette) opens the override's file through VS Code's own URL (`vscode://file/`), never the way the system opens its type: Windows runs a `.js` file with Windows Script Host. When the system says it can't open that URL (no VS Code), the toast offers **Show in folder**, which the Explorer row's menu has too. What is saved there is served (§5, Edits made elsewhere), and the page reloads as for a save (if that setting is on, and the override is on); a toast names the file. A tab without edits shows the new text as saved (undo brings back the old). A tab with unsaved edits keeps them, and a banner says the file changed: **Use that version** swaps them for the file (undo brings them back), and saving replaces the file with the tab's text. Until then no undo makes the tab saved, since none of its texts is the file's |
| Match row | Choose exact/glob/regex, edit the pattern, toggle ignore-query, Apply. Invalid regexes are rejected, and a pattern that no longer matches the source URL asks for confirmation |
| Build-hash hint | If the file name contains a build hash, offer a one-click glob (`main.3f9a1c2b.js` → `main.*.js`, `index-BkT3x9aQ.js` → `index-*.js`) |
| Override a response | **Override response** in the Network panel opens the override that answers the request if there is one; otherwise the response the page got, pretty-printed when minified (as files are), in an unsaved tab marked "Live response". Its JSON gets a schema inferred from that response (every item of an array merged, nothing required, other keys allowed), so completion offers its keys and example values and a value of another type is a warning. Saving creates a response override matching the URL (exact, ignoring the query), the request's method and its GraphQL operation |
@@ -717,6 +720,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g
- Packaged builds drop `--remote-debugging-port`/`--remote-debugging-pipe` when the data folder is the default one (compared after resolving links, so pointing `CONSOLE_EDITOR_USER_DATA` at it doesn't count as another), as Chrome does for its default profile: otherwise any local program could start the app with a port and read the site view's logins.
- One instance per data folder (`app.requestSingleInstanceLock`), so two processes never write the same overrides and session files. A second launch hands its URL to the running window and exits; if the first is still starting, that URL replaces the one it was about to open. On macOS, where Finder and `open` reopen the running app instead of starting a second process, the app also takes URLs from `open-url` the same way (following Electron's documentation; not yet checked on a Mac). Runs from source use a separate `… (dev)` data folder, so they never share one with an installed copy.
- IPC inputs are type-checked; rules, rule edits, matchers and actions are parsed with the Zod schemas the forms use (`src/shared/rules`, `src/shared/actions`) before storage: unknown keys are dropped, a field of the wrong shape is refused by name (`Invalid rule: match`), and a value by what is wrong with it (header names are tokens, values hold no line breaks, protected headers are refused, at most 32 header changes and 200 rules per workspace). Files are read with the same schemas. Settings are filtered to known boolean keys. Source-map calls take only an http(s) bundle URL: main derives the map URL itself.
+- Opening an override in another editor takes only its id: main makes the path and VS Code's URL itself, and never hands the file to the system's handler for its type (Windows runs `.js` files). What another editor saves there is served as the app's own saves are.
- Original sources are only ever shown as editor text, and their names (which the page chooses) as plain labels with control characters removed.
- The page stack's detector runs in each frame's main world, where the page can change what it answers: only known ids and signals are kept, versions only when they look like one, and everything shown besides a version is the app's own text (§6.12). The React hook stand-in is a global the page can see; **Framework hooks** turns it off.
- The component inspector's adapter runs in the main world of the frame picked in, where the page could change what it answers: the answer is checked the same way (§6.13), and names, previews and keys are only ever shown as plain labels. Reading where a function is defined turns the page's Debugger domain on for a moment, with pauses skipped, and off again. Setting state runs in the page with a value the renderer wrote as JSON, parsed by main and passed as an argument, never as code.
@@ -780,7 +784,7 @@ The package manager is asked rather than electron-builder's `resources/package-t
**M2: Robustness and sharing**
- ✅ Iframes, including cross-site and nested ones (§6.5).
- ✅ Workers: dedicated, shared and service workers, and worklets (§6.6).
-- Watch `workspace/files` for external edits (edit in VS Code, the app reloads the page), with an "Open in external editor" action.
+- ✅ Edit override files in another editor: the app serves what is saved there and reloads the page, and **Open in VS Code** opens a file (§5, §7).
- ✅ Workspaces: a page, tabs and overrides per site or task (§5.1).
- Export/import a workspace's overrides as a zip or JSON, so a teammate can reproduce your fix.
- ✅ Response header overrides (CORS, CSP, cache) and request blocking (e.g. disable an analytics script): rules (§6.3).
diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts
index 05c1d9a2..e4df62af 100644
--- a/src/main/ipc/registerIpc.ts
+++ b/src/main/ipc/registerIpc.ts
@@ -1,13 +1,6 @@
import { ipcMain, shell, type IpcMainEvent, type IpcMainInvokeEvent } from 'electron';
import { IPC_CHANNEL } from '../../shared/ipcChannels';
-import {
- RESOURCE_KINDS,
- type CreateOverrideInput,
- type OverridePatch,
- type Rect,
- type SessionDraft,
- type WorkspacePatch,
-} from '../../shared/types';
+import type { Rect, SessionDraft, WorkspacePatch } from '../../shared/types';
import { HTTP_URL } from '../constants';
import { assertString } from './assertString';
import { registerActionIpc } from './registerActionIpc';
@@ -16,6 +9,7 @@ import { registerConsoleIpc } from './registerConsoleIpc';
import { registerHarIpc } from './registerHarIpc';
import { registerInspectorIpc } from './registerInspectorIpc';
import { registerNetworkIpc } from './registerNetworkIpc';
+import { registerOverrideIpc } from './registerOverrideIpc';
import { registerRuleIpc } from './registerRuleIpc';
import { registerSettingsIpc } from './registerSettingsIpc';
import { registerSourceMapIpc } from './registerSourceMapIpc';
@@ -65,40 +59,7 @@ export function registerIpc({ win, page, store, rules, settings, session, action
return page.getResourceContent(url);
});
- handle(IPC_CHANNEL.listOverrides, () => store.metas());
- handle(IPC_CHANNEL.getOverride, (id: unknown) => {
- assertString(id, 'id');
- return store.get(id);
- });
- handle(IPC_CHANNEL.getOverrideBase, (id: unknown) => {
- assertString(id, 'id');
- return store.base(id);
- });
- handle(IPC_CHANNEL.createOverride, async (input: CreateOverrideInput) => {
- assertString(input?.sourceUrl, 'sourceUrl');
- assertString(input.content, 'content');
- if (input.base !== undefined) assertString(input.base, 'base');
- if (!RESOURCE_KINDS.includes(input.kind)) throw new Error(`Unsupported kind ${String(input.kind)}`);
- const created = await store.create(input);
- await page.overridesChanged();
- return store.meta(created.id);
- });
- handle(IPC_CHANNEL.updateOverride, async (id: unknown, patch: OverridePatch) => {
- assertString(id, 'id');
- await store.update(id, patch);
- // Patterns follow the match, the switch and Send request (which stage a response override pauses at).
- await page.overridesChanged(patch.match !== undefined || patch.enabled !== undefined || patch.response !== undefined);
- return store.meta(id);
- });
- handle(IPC_CHANNEL.deleteOverride, async (id: unknown) => {
- assertString(id, 'id');
- await store.remove(id);
- await page.overridesChanged();
- });
- handle(IPC_CHANNEL.revealOverridesFolder, async () => {
- await shell.openPath(store.filesDir);
- });
-
+ registerOverrideIpc(handle, store, page);
registerRuleIpc(handle, rules, page);
registerSettingsIpc(handleActions, { settings, page, updates, send });
diff --git a/src/main/ipc/registerOverrideIpc.ts b/src/main/ipc/registerOverrideIpc.ts
new file mode 100644
index 00000000..11f92097
--- /dev/null
+++ b/src/main/ipc/registerOverrideIpc.ts
@@ -0,0 +1,54 @@
+import { shell } from 'electron';
+import { IPC_CHANNEL } from '../../shared/ipcChannels';
+import { RESOURCE_KINDS, type CreateOverrideInput, type OverridePatch } from '../../shared/types';
+import { vscodeUrl } from '../overrideFiles';
+import type { PageController } from '../PageController';
+import type { OverrideStore } from '../store/OverrideStore';
+import { assertString } from './assertString';
+import type { IpcHandle } from './types';
+
+/** The overrides' channels, for the editor UI. 'overrides-changed' is sent before each change's reply resolves. */
+export function registerOverrideIpc(handle: IpcHandle, store: OverrideStore, page: PageController): void {
+ handle(IPC_CHANNEL.listOverrides, () => store.metas());
+ handle(IPC_CHANNEL.getOverride, (id: unknown) => {
+ assertString(id, 'id');
+ return store.get(id);
+ });
+ handle(IPC_CHANNEL.getOverrideBase, (id: unknown) => {
+ assertString(id, 'id');
+ return store.base(id);
+ });
+ handle(IPC_CHANNEL.createOverride, async (input: CreateOverrideInput) => {
+ assertString(input?.sourceUrl, 'sourceUrl');
+ assertString(input.content, 'content');
+ if (input.base !== undefined) assertString(input.base, 'base');
+ if (!RESOURCE_KINDS.includes(input.kind)) throw new Error(`Unsupported kind ${String(input.kind)}`);
+ const created = await store.create(input);
+ await page.overridesChanged();
+ return store.meta(created.id);
+ });
+ handle(IPC_CHANNEL.updateOverride, async (id: unknown, patch: OverridePatch) => {
+ assertString(id, 'id');
+ await store.update(id, patch);
+ // Patterns follow the match, the switch and Send request (which stage a response override pauses at).
+ await page.overridesChanged(patch.match !== undefined || patch.enabled !== undefined || patch.response !== undefined);
+ return store.meta(id);
+ });
+ handle(IPC_CHANNEL.deleteOverride, async (id: unknown) => {
+ assertString(id, 'id');
+ await store.remove(id);
+ await page.overridesChanged();
+ });
+ handle(IPC_CHANNEL.revealOverridesFolder, async () => {
+ await shell.openPath(store.filesDir);
+ });
+ // Opened by VS Code's own URL, never as the system opens the file type: Windows runs a .js file.
+ handle(IPC_CHANNEL.openOverrideInEditor, async (id: unknown) => {
+ assertString(id, 'id');
+ await shell.openExternal(vscodeUrl(store.contentPath(id)));
+ });
+ handle(IPC_CHANNEL.showOverrideFile, (id: unknown) => {
+ assertString(id, 'id');
+ shell.showItemInFolder(store.contentPath(id));
+ });
+}
diff --git a/src/main/launch/createWindow.ts b/src/main/launch/createWindow.ts
index 1bb1dca9..96bfaea9 100644
--- a/src/main/launch/createWindow.ts
+++ b/src/main/launch/createWindow.ts
@@ -6,6 +6,7 @@ import type { AppEvent } from '../../shared/types';
import { REPO_URL } from '../appInfo';
import { ActionsWindow } from '../ActionsWindow';
import { installMenu } from '../installMenu';
+import { watchOverrideFiles } from '../overrideFiles';
import { registerIpc } from '../ipc';
import { PageController } from '../PageController';
import { WorkspaceController } from '../WorkspaceController';
@@ -55,6 +56,8 @@ export async function createWindow(updateFeed: string | undefined): Promise {
updates.dispose();
+ fileEdits.stop();
// The website's and the Actions panel's own windows go with the editor (and open again next time).
page.window.dispose();
actionsWindow.dispose();
diff --git a/src/main/overrideFiles/OverrideFileWatcher.ts b/src/main/overrideFiles/OverrideFileWatcher.ts
new file mode 100644
index 00000000..f7d52646
--- /dev/null
+++ b/src/main/overrideFiles/OverrideFileWatcher.ts
@@ -0,0 +1,69 @@
+import { watch, type FSWatcher } from 'node:fs';
+import { EDIT_SETTLE_MS } from './constants';
+import type { FileWatcherDeps } from './types';
+
+/**
+ * Watches the folder of override files for edits made in another editor (VS Code), and hands the
+ * overrides whose files changed to `deps.onEdited` in one batch once the folder has been quiet for
+ * `EDIT_SETTLE_MS`. The app's own writes come through here too; `take` finds them unchanged.
+ */
+export class OverrideFileWatcher {
+ private watcher: FSWatcher | null = null;
+ private timer: ReturnType | undefined;
+ private readonly touched = new Set();
+ /** A change the platform didn't name a file for: every override is checked. */
+ private unnamed = false;
+ /** The batch being read: the next one waits for it, so a slow read is never overtaken. */
+ private reading: Promise = Promise.resolve();
+
+ constructor(private readonly deps: FileWatcherDeps) {}
+
+ start(): void {
+ try {
+ // Not persistent: watching never keeps the app from quitting.
+ this.watcher = watch(this.deps.dir, { persistent: false }, (_event, name) => this.touch(name));
+ } catch (err) {
+ console.warn(`Not watching ${this.deps.dir} for edits made elsewhere: ${(err as Error).message}`);
+ return;
+ }
+ this.watcher.on('error', (err) => {
+ console.warn(`Stopped watching ${this.deps.dir}: ${err.message}`);
+ this.stop();
+ });
+ }
+
+ stop(): void {
+ this.watcher?.close();
+ this.watcher = null;
+ clearTimeout(this.timer);
+ }
+
+ private touch(name: string | null): void {
+ const id = name ? this.deps.idOf(name) : null;
+ if (name && !id) return;
+ if (id) this.touched.add(id);
+ else this.unnamed = true;
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => this.settle(), EDIT_SETTLE_MS);
+ }
+
+ private settle(): void {
+ const ids = this.unnamed ? this.deps.ids() : [...this.touched];
+ this.touched.clear();
+ this.unnamed = false;
+ // A batch that fails never holds back the next.
+ this.reading = this.reading.then(() => this.take(ids)).catch((err: Error) => console.warn(`Could not take edits made elsewhere: ${err.message}`));
+ }
+
+ private async take(ids: string[]): Promise {
+ const edited: string[] = [];
+ for (const id of ids) {
+ try {
+ if (await this.deps.take(id)) edited.push(id);
+ } catch (err) {
+ console.warn(`Could not read the edited file of override ${id}: ${(err as Error).message}`);
+ }
+ }
+ if (edited.length) this.deps.onEdited(edited);
+ }
+}
diff --git a/src/main/overrideFiles/constants.ts b/src/main/overrideFiles/constants.ts
new file mode 100644
index 00000000..43f687e2
--- /dev/null
+++ b/src/main/overrideFiles/constants.ts
@@ -0,0 +1,8 @@
+/**
+ * How long a folder of override files has to stay quiet before the files changed in it are read: an
+ * editor's save can be several writes (truncate, write, rename over), and a checkout touches many files.
+ */
+export const EDIT_SETTLE_MS = 200;
+
+/** The scheme VS Code registers on every platform to open a file: `vscode://file/`. */
+export const VSCODE_FILE_URL = 'vscode://file';
diff --git a/src/main/overrideFiles/index.ts b/src/main/overrideFiles/index.ts
new file mode 100644
index 00000000..1b260c7b
--- /dev/null
+++ b/src/main/overrideFiles/index.ts
@@ -0,0 +1,3 @@
+export { OverrideFileWatcher } from './OverrideFileWatcher';
+export { watchOverrideFiles } from './watchOverrideFiles';
+export { vscodeUrl } from './vscodeUrl';
diff --git a/src/main/overrideFiles/types.ts b/src/main/overrideFiles/types.ts
new file mode 100644
index 00000000..3c29c333
--- /dev/null
+++ b/src/main/overrideFiles/types.ts
@@ -0,0 +1,12 @@
+export interface FileWatcherDeps {
+ /** The folder of override files. */
+ dir: string;
+ /** The id of the override a file in `dir` holds the content of, or null for any other file. */
+ idOf(name: string): string | null;
+ /** The overrides to check when the platform doesn't say which file changed. */
+ ids(): string[];
+ /** Takes an override's file as it is on disk: true when another editor changed it. */
+ take(id: string): Promise;
+ /** Overrides whose file another editor changed, once they are served as it is. */
+ onEdited(ids: string[]): void;
+}
diff --git a/src/main/overrideFiles/vscodeUrl.ts b/src/main/overrideFiles/vscodeUrl.ts
new file mode 100644
index 00000000..a587ccff
--- /dev/null
+++ b/src/main/overrideFiles/vscodeUrl.ts
@@ -0,0 +1,7 @@
+import { pathToFileURL } from 'node:url';
+import { VSCODE_FILE_URL } from './constants';
+
+/** The URL that opens the file at `path` in VS Code: its file URL's path (encoded, `/C:/…` on Windows) under VS Code's scheme. */
+export function vscodeUrl(path: string): string {
+ return `${VSCODE_FILE_URL}${pathToFileURL(path).pathname}`;
+}
diff --git a/src/main/overrideFiles/watchOverrideFiles.ts b/src/main/overrideFiles/watchOverrideFiles.ts
new file mode 100644
index 00000000..2f95b276
--- /dev/null
+++ b/src/main/overrideFiles/watchOverrideFiles.ts
@@ -0,0 +1,26 @@
+import type { AppEvent } from '../../shared/types';
+import type { PageController } from '../PageController';
+import { contentFileId, type OverrideStore } from '../store/OverrideStore';
+import { OverrideFileWatcher } from './OverrideFileWatcher';
+
+/**
+ * Serves an override's file as another editor saved it, and tells the editor UI which of the active
+ * workspace's overrides changed so it can update their tabs and reload the page. Another workspace's
+ * are served as edited once it is active again.
+ */
+export function watchOverrideFiles(store: OverrideStore, page: PageController, send: (event: AppEvent) => void): OverrideFileWatcher {
+ const watcher = new OverrideFileWatcher({
+ dir: store.filesDir,
+ idOf: contentFileId,
+ ids: () => store.list().map((o) => o.id),
+ take: (id) => store.takeFileEdit(id),
+ onEdited: (ids) => {
+ const shown = new Set(store.list().map((o) => o.id));
+ const overrideIds = ids.filter((id) => shown.has(id));
+ // Only the content changed: the Fetch patterns stay.
+ if (overrideIds.length) void page.overridesChanged(false).then(() => send({ type: 'overrides-edited', overrideIds }));
+ },
+ });
+ watcher.start();
+ return watcher;
+}
diff --git a/src/main/store/OverrideStore/CommittedOverrides.ts b/src/main/store/OverrideStore/CommittedOverrides.ts
index 6ebe4443..24f80d27 100644
--- a/src/main/store/OverrideStore/CommittedOverrides.ts
+++ b/src/main/store/OverrideStore/CommittedOverrides.ts
@@ -43,4 +43,15 @@ export class CommittedOverrides {
return result;
});
}
+
+ /** As `mutate`, but when `change` answers false nothing is written and the overrides stay as they were. */
+ mutateIf(change: (overrides: Map) => Promise): Promise {
+ return this.writes.run(async () => {
+ const next = new Map(this.current);
+ if (!(await change(next))) return false;
+ await writeIndex(this.indexPath, next);
+ this.current = next;
+ return true;
+ });
+ }
}
diff --git a/src/main/store/OverrideStore/ContentFiles.ts b/src/main/store/OverrideStore/ContentFiles.ts
index b4eaf331..4872a4ff 100644
--- a/src/main/store/OverrideStore/ContentFiles.ts
+++ b/src/main/store/OverrideStore/ContentFiles.ts
@@ -15,7 +15,8 @@ import type { ContentFile, FileOwner } from './types';
export class ContentFiles {
constructor(readonly dir: string) {}
- private path(meta: FileOwner, which: ContentFile): string {
+ /** Where an override's file is: its served content, or the text editing started from. */
+ path(meta: FileOwner, which: ContentFile): string {
const ext = EXTENSIONS[meta.kind];
return join(this.dir, which === 'content' ? `${meta.id}.${ext}` : `${meta.id}.${BASE_MARK}.${ext}`);
}
@@ -28,16 +29,21 @@ export class ContentFiles {
return readFile(this.path(meta, which), 'utf8');
}
- /** The content editing started from (for diffs): the content itself unless a separate base was saved. */
- async base(o: Override): Promise {
+ /** An override's file, or null when it isn't there (another editor may be replacing it). */
+ async readIfThere(meta: FileOwner, which: ContentFile): Promise {
try {
- return await this.read(o, 'base');
+ return await this.read(meta, which);
} catch (err) {
- if ((err as NodeJS.ErrnoException).code === FILE_NOT_FOUND) return o.content;
+ if ((err as NodeJS.ErrnoException).code === FILE_NOT_FOUND) return null;
throw err;
}
}
+ /** The content editing started from (for diffs): the content itself unless a separate base was saved. */
+ async base(o: Override): Promise {
+ return (await this.readIfThere(o, 'base')) ?? o.content;
+ }
+
write(meta: FileOwner, which: ContentFile, text: string): Promise {
return writeAtomic(this.path(meta, which), text);
}
diff --git a/src/main/store/OverrideStore/OverrideStore.ts b/src/main/store/OverrideStore/OverrideStore.ts
index 56e14ee2..dbcfbf52 100644
--- a/src/main/store/OverrideStore/OverrideStore.ts
+++ b/src/main/store/OverrideStore/OverrideStore.ts
@@ -9,7 +9,9 @@ import { FILES_DIR, INDEX_FILE } from './constants';
import { ContentFiles } from './ContentFiles';
import { newOverride } from './newOverride';
import { patchOverride } from './patchOverride';
+import { removeWorkspaceOverrides } from './removeWorkspaceOverrides';
import { responseFieldsOf } from './responseFieldsOf';
+import { takeFileEdit } from './takeFileEdit';
/**
* Persists overrides on disk:
@@ -78,6 +80,16 @@ export class OverrideStore {
return this.files.base(this.get(id));
}
+ /** The file an override's served content is kept in, for another editor to open. */
+ contentPath(id: string): string {
+ return this.files.path(this.get(id), 'content');
+ }
+
+ /** Takes an override's file as it is on disk when another editor changed it: true when it did. */
+ takeFileEdit(id: string): Promise {
+ return takeFileEdit(this.committed, this.files, id);
+ }
+
async create(input: CreateOverrideInput): Promise {
const match = input.match ?? defaultMatcherFor(input.sourceUrl);
assertMatcher(match);
@@ -118,14 +130,8 @@ export class OverrideStore {
}
/** Deletes every override of a workspace. */
- async removeWorkspace(workspaceId: string): Promise {
- if (!this.committed.all().some((o) => o.workspaceId === workspaceId)) return;
- const gone = await this.committed.mutate(async (overrides) => {
- const gone = [...overrides.values()].filter((o) => o.workspaceId === workspaceId);
- for (const o of gone) overrides.delete(o.id);
- return gone;
- });
- for (const o of gone) await this.files.remove(o);
+ removeWorkspace(workspaceId: string): Promise {
+ return removeWorkspaceOverrides(this.committed, this.files, workspaceId);
}
/**
diff --git a/src/main/store/OverrideStore/contentFileId.ts b/src/main/store/OverrideStore/contentFileId.ts
new file mode 100644
index 00000000..4e32accb
--- /dev/null
+++ b/src/main/store/OverrideStore/contentFileId.ts
@@ -0,0 +1,9 @@
+import { EXTENSIONS, ID_BYTES } from './constants';
+
+/** An override's content file, `.`: not its base (`.base.`), nor a temp file on its way in. */
+const CONTENT_FILE = new RegExp(`^([0-9a-f]{${ID_BYTES * 2}})\\.(?:${Object.values(EXTENSIONS).join('|')})$`);
+
+/** The id of the override whose content file is named `name`, or null for any other file. */
+export function contentFileId(name: string): string | null {
+ return CONTENT_FILE.exec(name)?.[1] ?? null;
+}
diff --git a/src/main/store/OverrideStore/index.ts b/src/main/store/OverrideStore/index.ts
index e766702d..e77ca7c8 100644
--- a/src/main/store/OverrideStore/index.ts
+++ b/src/main/store/OverrideStore/index.ts
@@ -1 +1,2 @@
export { OverrideStore } from './OverrideStore';
+export { contentFileId } from './contentFileId';
diff --git a/src/main/store/OverrideStore/removeWorkspaceOverrides.ts b/src/main/store/OverrideStore/removeWorkspaceOverrides.ts
new file mode 100644
index 00000000..48d1cbdb
--- /dev/null
+++ b/src/main/store/OverrideStore/removeWorkspaceOverrides.ts
@@ -0,0 +1,13 @@
+import type { CommittedOverrides } from './CommittedOverrides';
+import type { ContentFiles } from './ContentFiles';
+
+/** Deletes every override of a workspace: from the index first, then their files. */
+export async function removeWorkspaceOverrides(committed: CommittedOverrides, files: ContentFiles, workspaceId: string): Promise {
+ if (!committed.all().some((o) => o.workspaceId === workspaceId)) return;
+ const gone = await committed.mutate(async (overrides) => {
+ const gone = [...overrides.values()].filter((o) => o.workspaceId === workspaceId);
+ for (const o of gone) overrides.delete(o.id);
+ return gone;
+ });
+ for (const o of gone) await files.remove(o);
+}
diff --git a/src/main/store/OverrideStore/takeFileEdit.ts b/src/main/store/OverrideStore/takeFileEdit.ts
new file mode 100644
index 00000000..209f087d
--- /dev/null
+++ b/src/main/store/OverrideStore/takeFileEdit.ts
@@ -0,0 +1,17 @@
+import type { CommittedOverrides } from './CommittedOverrides';
+import type { ContentFiles } from './ContentFiles';
+
+/**
+ * Takes override `id`'s file as it is on disk when another editor changed it: true when it did.
+ * Queued with the other changes, so the app's own writes read back as unchanged.
+ */
+export function takeFileEdit(committed: CommittedOverrides, files: ContentFiles, id: string): Promise {
+ return committed.mutateIf(async (overrides) => {
+ const current = overrides.get(id);
+ const text = current ? await files.readIfThere(current, 'content') : null;
+ // A file being replaced (written elsewhere, then renamed over it) is taken once it is back.
+ if (!current || text === null || text === current.content) return false;
+ overrides.set(id, { ...current, content: text, updatedAt: Date.now() });
+ return true;
+ });
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index e2413911..305c645d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -31,6 +31,8 @@ const api: ConsoleEditorApi = {
updateOverride: (id, patch) => ipcRenderer.invoke(IPC_CHANNEL.updateOverride, id, patch),
deleteOverride: (id) => ipcRenderer.invoke(IPC_CHANNEL.deleteOverride, id),
revealOverridesFolder: () => ipcRenderer.invoke(IPC_CHANNEL.revealOverridesFolder),
+ openOverrideInEditor: (id) => ipcRenderer.invoke(IPC_CHANNEL.openOverrideInEditor, id),
+ showOverrideFile: (id) => ipcRenderer.invoke(IPC_CHANNEL.showOverrideFile, id),
listRules: () => ipcRenderer.invoke(IPC_CHANNEL.listRules),
createRule: (input) => ipcRenderer.invoke(IPC_CHANNEL.createRule, input),
diff --git a/src/renderer/src/app/model/bridge/appEventHandlers.ts b/src/renderer/src/app/model/bridge/appEventHandlers.ts
index 5be062d5..3f16ba5b 100644
--- a/src/renderer/src/app/model/bridge/appEventHandlers.ts
+++ b/src/renderer/src/app/model/bridge/appEventHandlers.ts
@@ -7,6 +7,7 @@ import { useNetworkStore } from '@/entities/network-request';
import { usePageStackStore } from '@/entities/page-stack';
import { useSettingsStore } from '@/entities/settings';
import { useWorkspaceStore } from '@/entities/workspace';
+import { takeOutsideEdits } from '@/features/override/external-editor';
import { receiveEntries } from '@/features/filter-console';
import { receiveHeld } from '@/features/network/held';
import { handleUpdateState } from '@/features/update-app';
@@ -46,6 +47,7 @@ export const APP_EVENT_HANDLERS: AppEventHandlers = {
error: showAppError,
'page-state': applyPageState,
'overrides-changed': syncOverrides,
+ 'overrides-edited': (event) => void takeOutsideEdits(event.overrideIds),
'rules-changed': syncRules,
'workspaces-changed': (event) => useWorkspaceStore.getState().setAll(event.state),
'workspace-favicon': (event) => useWorkspaceStore.getState().setFavicon(event.id, event.favicon),
diff --git a/src/renderer/src/entities/editor-tab/index.ts b/src/renderer/src/entities/editor-tab/index.ts
index 02bcde29..1932a24a 100644
--- a/src/renderer/src/entities/editor-tab/index.ts
+++ b/src/renderer/src/entities/editor-tab/index.ts
@@ -22,6 +22,7 @@ export {
getTabBase,
setTabBase,
markTabSaved,
+ setTabSavedText,
replaceTabText,
editTabText,
disposeTabModel,
diff --git a/src/renderer/src/entities/editor-tab/model/models/index.ts b/src/renderer/src/entities/editor-tab/model/models/index.ts
index b6781c27..f28066dc 100644
--- a/src/renderer/src/entities/editor-tab/model/models/index.ts
+++ b/src/renderer/src/entities/editor-tab/model/models/index.ts
@@ -9,3 +9,4 @@ export { newTabId } from './newTabId';
export { onTabEdited } from './onTabEdited';
export { replaceTabText } from './replaceTabText';
export { setTabBase } from './setTabBase';
+export { setTabSavedText } from './setTabSavedText';
diff --git a/src/renderer/src/entities/editor-tab/model/models/setTabSavedText.ts b/src/renderer/src/entities/editor-tab/model/models/setTabSavedText.ts
new file mode 100644
index 00000000..109f750a
--- /dev/null
+++ b/src/renderer/src/entities/editor-tab/model/models/setTabSavedText.ts
@@ -0,0 +1,11 @@
+import { getTabModel } from './getTabModel';
+import { markTabSaved } from './markTabSaved';
+import { replaceTabText } from './replaceTabText';
+
+/** Makes `text` the tab's saved text, as one undoable edit: undo brings back what it showed, unsaved. */
+export function setTabSavedText(tabId: string, text: string): void {
+ const model = getTabModel(tabId);
+ if (!model) return;
+ if (model.getValue() !== text) replaceTabText(tabId, text);
+ markTabSaved(tabId, model.getAlternativeVersionId());
+}
diff --git a/src/renderer/src/entities/editor-tab/model/store/types.ts b/src/renderer/src/entities/editor-tab/model/store/types.ts
index 89609c44..91e1ca8a 100644
--- a/src/renderer/src/entities/editor-tab/model/store/types.ts
+++ b/src/renderer/src/entities/editor-tab/model/store/types.ts
@@ -24,6 +24,11 @@ export interface TabMeta {
held?: string;
/** Opened in highlight-only mode because the file is huge. */
lite: boolean;
+ /**
+ * Another editor changed its override's file while it held unsaved edits: that file is served, and
+ * the tab stays unsaved until it is saved over it or takes it. Not kept between runs.
+ */
+ editedOutside?: boolean;
dirty: boolean;
saving: boolean;
}
diff --git a/src/renderer/src/features/override/external-editor/index.ts b/src/renderer/src/features/override/external-editor/index.ts
new file mode 100644
index 00000000..68989a3a
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/index.ts
@@ -0,0 +1,4 @@
+export { openInEditor } from './model/openInEditor';
+export { showOverrideFile } from './model/showOverrideFile';
+export { takeFileVersion } from './model/takeFileVersion';
+export { takeOutsideEdits } from './model/takeOutsideEdits';
diff --git a/src/renderer/src/features/override/external-editor/model/constants.ts b/src/renderer/src/features/override/external-editor/model/constants.ts
new file mode 100644
index 00000000..80ba689a
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/constants.ts
@@ -0,0 +1,5 @@
+/**
+ * A version no text of a tab has: marked saved, it keeps the tab unsaved whatever is undone, for a
+ * tab none of whose texts is the one its file holds now.
+ */
+export const NO_SAVED_VERSION = -1;
diff --git a/src/renderer/src/features/override/external-editor/model/openInEditor.ts b/src/renderer/src/features/override/external-editor/model/openInEditor.ts
new file mode 100644
index 00000000..fc785d5b
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/openInEditor.ts
@@ -0,0 +1,17 @@
+import { api, errorMessage } from '@/shared/api';
+import { toast } from '@/shared/ui/toast';
+import { showOverrideFile } from './showOverrideFile';
+
+/** Opens an override's file in VS Code: what is saved there is served, and the page reloads with it. */
+export async function openInEditor(overrideId: string): Promise {
+ try {
+ await api.openOverrideInEditor(overrideId);
+ } catch (err) {
+ toast({
+ title: 'Could not open VS Code',
+ description: errorMessage(err),
+ tone: 'danger',
+ action: { label: 'Show in folder', onClick: () => void showOverrideFile(overrideId) },
+ });
+ }
+}
diff --git a/src/renderer/src/features/override/external-editor/model/showOverrideFile.ts b/src/renderer/src/features/override/external-editor/model/showOverrideFile.ts
new file mode 100644
index 00000000..a4cccb53
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/showOverrideFile.ts
@@ -0,0 +1,11 @@
+import { api, errorMessage } from '@/shared/api';
+import { toast } from '@/shared/ui/toast';
+
+/** Shows an override's file in the system's file manager, to open it in any editor. */
+export async function showOverrideFile(overrideId: string): Promise {
+ try {
+ await api.showOverrideFile(overrideId);
+ } catch (err) {
+ toast({ title: 'Could not show the file', description: errorMessage(err), tone: 'danger' });
+ }
+}
diff --git a/src/renderer/src/features/override/external-editor/model/syncEditedTab.ts b/src/renderer/src/features/override/external-editor/model/syncEditedTab.ts
new file mode 100644
index 00000000..ac31278d
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/syncEditedTab.ts
@@ -0,0 +1,28 @@
+import { api } from '@/shared/api';
+import { getTabModel, markTabSaved, setTabSavedText, useTabStore } from '@/entities/editor-tab';
+import { NO_SAVED_VERSION } from './constants';
+
+/**
+ * Brings the tab showing an override, if one does, up to its file after another editor changed it:
+ * a tab without edits shows the new text as saved; one with edits keeps them, stays unsaved and says so.
+ */
+export async function syncEditedTab(overrideId: string): Promise {
+ if (!useTabStore.getState().tabs.some((t) => t.overrideId === overrideId)) return;
+ let content: string;
+ try {
+ ({ content } = await api.getOverride(overrideId));
+ } catch {
+ // Deleted meanwhile: `overrides-changed` unlinks its tab.
+ return;
+ }
+ // Looked up again: it may have closed, or been edited, while the text came.
+ const tab = useTabStore.getState().tabs.find((t) => t.overrideId === overrideId);
+ if (!tab) return;
+ if (tab.dirty && getTabModel(tab.id)?.getValue() !== content) {
+ markTabSaved(tab.id, NO_SAVED_VERSION);
+ useTabStore.getState().patch(tab.id, { editedOutside: true });
+ return;
+ }
+ setTabSavedText(tab.id, content);
+ useTabStore.getState().patch(tab.id, { editedOutside: undefined });
+}
diff --git a/src/renderer/src/features/override/external-editor/model/takeFileVersion.ts b/src/renderer/src/features/override/external-editor/model/takeFileVersion.ts
new file mode 100644
index 00000000..f417152b
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/takeFileVersion.ts
@@ -0,0 +1,16 @@
+import { api, errorMessage } from '@/shared/api';
+import { toast } from '@/shared/ui/toast';
+import { setTabSavedText, useTabStore } from '@/entities/editor-tab';
+
+/** Swaps a tab's unsaved edits for its override's file as another editor saved it; undo brings them back. */
+export async function takeFileVersion(tabId: string): Promise {
+ const tab = useTabStore.getState().tabs.find((t) => t.id === tabId);
+ if (!tab?.overrideId) return;
+ try {
+ const { content } = await api.getOverride(tab.overrideId);
+ setTabSavedText(tabId, content);
+ useTabStore.getState().patch(tabId, { editedOutside: undefined });
+ } catch (err) {
+ toast({ title: 'Could not read the file', description: errorMessage(err), tone: 'danger' });
+ }
+}
diff --git a/src/renderer/src/features/override/external-editor/model/takeOutsideEdits.ts b/src/renderer/src/features/override/external-editor/model/takeOutsideEdits.ts
new file mode 100644
index 00000000..899c2cdc
--- /dev/null
+++ b/src/renderer/src/features/override/external-editor/model/takeOutsideEdits.ts
@@ -0,0 +1,26 @@
+import { api } from '@/shared/api';
+import { TOAST_DURATION } from '@/shared/config';
+import { toast } from '@/shared/ui/toast';
+import { overrideLabel, useOverrideStore } from '@/entities/override';
+import { useSettingsStore } from '@/entities/settings';
+import { syncEditedTab } from './syncEditedTab';
+
+/**
+ * After another editor changed overrides' files (now served): updates their tabs, says so, and reloads
+ * the page as a save does.
+ */
+export async function takeOutsideEdits(overrideIds: readonly string[]): Promise {
+ for (const id of overrideIds) await syncEditedTab(id);
+ const { byId } = useOverrideStore.getState();
+ const edited = overrideIds.flatMap((id) => byId[id] ?? []);
+ const [first] = edited;
+ if (!first) return;
+ const reload = useSettingsStore.getState().settings.autoReloadOnSave && edited.some((o) => o.enabled);
+ toast({
+ title: edited.length > 1 ? `${edited.length} overrides changed in another editor` : `${overrideLabel(first.sourceUrl, first.request)} changed in another editor`,
+ description: reload ? 'Reloading the page with the new version.' : undefined,
+ tone: 'success',
+ duration: TOAST_DURATION.confirm,
+ });
+ if (reload) await api.reload().catch(() => undefined);
+}
diff --git a/src/renderer/src/features/save-override/model/save/doSave.ts b/src/renderer/src/features/save-override/model/save/doSave.ts
index 748534c2..9fff602c 100644
--- a/src/renderer/src/features/save-override/model/save/doSave.ts
+++ b/src/renderer/src/features/save-override/model/save/doSave.ts
@@ -37,6 +37,8 @@ export async function doSave(tabId: string): Promise {
patch(tabId, { overrideId: created.id, request: undefined, response: undefined });
}
markTabSaved(tabId, version);
+ // Saved over what another editor wrote, if it had.
+ patch(tabId, { editedOutside: undefined });
const reload = useSettingsStore.getState().settings.autoReloadOnSave;
toast({ title: `Saved ${fileName(tab.url)}`, description: reload ? 'Reloading the page with your version.' : undefined, tone: 'success', duration: TOAST_DURATION.confirm });
if (reload) await api.reload();
diff --git a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx
index 3ebfa03d..02874a37 100644
--- a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx
+++ b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx
@@ -12,9 +12,7 @@ import { usePageStore } from '@/entities/page';
import { selectRuleList, useRuleStore } from '@/entities/rule';
import { workerScriptUrl, WORKER_NAME } from '@/entities/resource';
import { useWorkspaceStore, workspaceDetail, workspaceLabel } from '@/entities/workspace';
-import { compareWithLive, toggleBaseDiff } from '@/features/compare-changes';
import { attachPage, detachPage } from '@/features/detach-page';
-import { formatTab } from '@/features/format-document';
import { openPageDevTools, reloadPage } from '@/features/navigate-page';
import { openOverride, openResource } from '@/features/open-resource';
import { setOverrideEnabled } from '@/features/toggle-override';
@@ -24,10 +22,10 @@ import { usePalette } from '../model/palette';
import { useActionGroup } from '../model/useActionGroup';
import { sourceActions, useOriginalSources } from '../model/sources';
import { KIND_ICON, OVERRIDE_ITEM_PREFIX, WORKSPACE_ITEM_PREFIX } from './constants';
+import { fileTabItems } from './fileTabItems';
import { inspectItems, type InspectLog } from './inspectItems';
import { newRuleItems } from './newRuleItems';
import { ruleItems } from './ruleItems';
-import { saveItem } from './saveItem';
export interface AppCommandPaletteProps {
onShowSettings(): void;
@@ -89,14 +87,7 @@ export function AppCommandPalette({ onShowSettings, onShowExplorer, onFocusAddre
const actions: CommandGroup = {
heading: 'Actions',
items: [
- ...(active
- ? [
- saveItem(active),
- { id: 'format', label: 'Pretty-print this file', icon: icons.PrettifyIcon, shortcut: SHORTCUT.format, onSelect: () => void formatTab() },
- { id: 'diff', label: 'Diff with where you started', icon: icons.DiffIcon, shortcut: SHORTCUT.diff, onSelect: toggleBaseDiff },
- ...(active.overrideId ? [{ id: 'live', label: 'Compare with the live file', icon: icons.GlobeIcon, onSelect: () => void compareWithLive() }] : []),
- ]
- : []),
+ ...fileTabItems(active),
...sourceActions(active, activeSource, onShowExplorer),
{ id: 'reload', label: 'Reload page', icon: icons.ReloadIcon, shortcut: SHORTCUT.reload, onSelect: () => void reloadPage() },
{ id: 'console', label: 'Toggle console', icon: icons.ConsoleIcon, shortcut: SHORTCUT.console, keywords: ['logs', 'iframe', 'frame', 'run'], onSelect: onToggleConsole },
diff --git a/src/renderer/src/widgets/command-palette/ui/fileTabItems.ts b/src/renderer/src/widgets/command-palette/ui/fileTabItems.ts
new file mode 100644
index 00000000..c7cd9a0c
--- /dev/null
+++ b/src/renderer/src/widgets/command-palette/ui/fileTabItems.ts
@@ -0,0 +1,25 @@
+import { SHORTCUT } from '@common/constants';
+import { icons } from '@/shared/config';
+import type { CommandItem } from '@/shared/ui/command-palette';
+import type { TabMeta } from '@/entities/editor-tab';
+import { compareWithLive, toggleBaseDiff } from '@/features/compare-changes';
+import { openInEditor } from '@/features/override/external-editor';
+import { formatTab } from '@/features/format-document';
+import { saveItem } from './saveItem';
+
+/** The active file tab's commands: save, pretty-print, diff and, for an override, compare with the live file or open it in VS Code. */
+export function fileTabItems(active: TabMeta | null): CommandItem[] {
+ if (!active) return [];
+ const { overrideId } = active;
+ return [
+ saveItem(active),
+ { id: 'format', label: 'Pretty-print this file', icon: icons.PrettifyIcon, shortcut: SHORTCUT.format, onSelect: () => void formatTab() },
+ { id: 'diff', label: 'Diff with where you started', icon: icons.DiffIcon, shortcut: SHORTCUT.diff, onSelect: toggleBaseDiff },
+ ...(overrideId
+ ? [
+ { id: 'live', label: 'Compare with the live file', icon: icons.GlobeIcon, onSelect: () => void compareWithLive() },
+ { id: 'open-in-editor', label: 'Open in VS Code', icon: icons.ExternalLinkIcon, keywords: ['external editor', 'edit'], onSelect: () => void openInEditor(overrideId) },
+ ]
+ : []),
+ ];
+}
diff --git a/src/renderer/src/widgets/editor-panel/ui/Banners/Banners.tsx b/src/renderer/src/widgets/editor-panel/ui/Banners/Banners.tsx
index 9f7fc5d9..0b00fcea 100644
--- a/src/renderer/src/widgets/editor-panel/ui/Banners/Banners.tsx
+++ b/src/renderer/src/widgets/editor-panel/ui/Banners/Banners.tsx
@@ -9,12 +9,13 @@ import { usePageStore } from '@/entities/page';
import { blockingRuleFor, useRuleStore } from '@/entities/rule';
import { compareWithLive } from '@/features/compare-changes';
import { applyMatch, buildHashGlob } from '@/features/edit-match-rule';
+import { takeFileVersion } from '@/features/override/external-editor';
import { openRuleEditor } from '@/features/rule/edit';
import { setRuleEnabled } from '@/features/rule/toggle';
import { isPageDocument } from '../../lib/isPageDocument';
import { Banner } from './Banner';
-/** Contextual hints under the file header: a rule blocking the file, build-hash names, upstream changes, large files. */
+/** Contextual hints under the file header: a rule blocking the file, build-hash names, edits made elsewhere, upstream changes, large files. */
export function Banners({ override, tab }: { override?: OverrideMeta; tab: TabMeta }) {
const changed = useOverrideStore((s) => (override ? !!s.upstreamChanged[override.id] : false));
// The page's own document is never blocked, whatever the rules say.
@@ -59,6 +60,21 @@ export function Banners({ override, tab }: { override?: OverrideMeta; tab: TabMe
This file name has a build hash, so the override stops matching after the next deploy.
) : null}
+ {tab.editedOutside ? (
+ void takeFileVersion(tab.id)}>
+ Use that version
+
+ }
+ >
+ This file was changed in another editor, and that version is served. Saving here replaces it with yours.
+
+ ) : null}
{changed ? (
(diff === 'live' ? closeDiff() : void compareWithLive(tab.id))}
/>
) : null}
+ {override ? void openInEditor(override.id)} /> : null}