<script lang="ts">
import { onMount } from "svelte";
import { contextMenu as placeMenu } from "../../utils/menu.js";
import MonacoEditor from "../layout/MonacoEditor.svelte";
import MarkdownFileViewer from "./MarkdownFileViewer.svelte";
import AppIcon from "../layout/AppIcon.svelte";
import { explorer as explorerStore } from "../../stores/explorer.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { notifications } from "../../stores/notifications.svelte.js";
import { scoped, viewWorktree } from "../../stores/worktree-scope.svelte.js";
const explorer = scoped(explorerStore);
interface Props {
path: string;
/** The line to open at, when whoever opened this named one. */
line?: number | null;
column?: number | null;
}
let { path, line = null, column = null }: Props = $props();
/** The worktree this tab's file is of, as it was when the tab opened. */
const worktree = viewWorktree("the file editor");
let file = $derived(explorer.files.get(path));
let unsupportedContent = $derived(isUnsupportedTextContentError(file?.error));
let editorContext = $derived(explorer.editorContextForPath(path));
let titleMenuItems = $derived(explorer.menuCommands("editor/title", editorContext));
let contextMenuItems = $derived(explorer.menuCommands("editor/context", editorContext));
let isMarkdown = $derived.by(() => {
return explorer.languageIdForPath(path) === "markdown";
});
let isDirty = $derived(file?.editedContent !== null && file?.editedContent !== file?.content);
let contextMenu = $state<{ x: number; y: number } | null>(null);
function isUnsupportedTextContentError(error: string | null | undefined): boolean {
if (!error) {
return false;
}
return error.includes("is not valid UTF-8 text");
}
onMount(() => {
void explorer.ensureWorkbenchModel();
});
async function handleSave() {
try {
await explorer.saveFile(path);
} catch (error) {
notifications.upsert(
`file-save:${path}`,
"error",
`Failed to save ${path.split("/").pop() ?? path}`,
error instanceof Error ? error.message : String(error),
"explorer",
);
}
}
async function handleLoadFromDisk() {
try {
await explorer.loadFile(path);
} catch (error) {
notifications.upsert(
`file-reload:${path}`,
"error",
`Failed to reload ${path.split("/").pop() ?? path}`,
error instanceof Error ? error.message : String(error),
"explorer",
);
}
}
async function handleOverwriteDisk() {
const file = explorer.files.get(path);
if (!file) return;
// Force-save by updating the expected mtime to the disk mtime so the
// server accepts the write even though the disk changed.
// We do this by reloading the mtime from a fresh HEAD then saving.
// The simplest approach: temporarily clear diskChanged and force-save.
// The backend's mtime check prevents races — if the disk changed *again*
// between our reload and this save, the server returns 409 and we surface it.
try {
// Get the current on-disk mtime so our expected_mtime matches.
const fresh = await explorer.loadFile(path);
// Restore edited content after reload.
if (file.editedContent !== null) {
explorer.setEditedContent(path, file.editedContent);
}
await explorer.saveFile(path);
} catch (error) {
notifications.upsert(
`file-overwrite:${path}`,
"error",
`Failed to overwrite ${path.split("/").pop() ?? path}`,
error instanceof Error ? error.message : String(error),
"explorer",
);
}
}
async function openContributedCommand(commandId: string) {
const command = explorer.commandById(commandId);
try {
const execution = await explorer.executeCommand(commandId);
const panel = execution.panels[0];
if (!panel) {
throw new Error(`extension command '${commandId}' completed without creating a supported workbench panel`);
}
workbench.openWebviewPanel(command.extension_id, panel, worktree);
} catch (error) {
notifications.upsert(
`extension-command:${commandId}`,
"error",
`Extension command failed`,
error instanceof Error ? error.message : String(error),
"extensions",
);
} finally {
contextMenu = null;
}
}
function handleEditorContextMenu(event: MouseEvent) {
if (contextMenuItems.length === 0) {
return;
}
event.preventDefault();
contextMenu = { x: event.clientX, y: event.clientY };
}
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
void handleSave();
}
}
$effect(() => {
if (!contextMenu) return;
function handleDismiss() {
contextMenu = null;
}
const id = setTimeout(() => {
document.addEventListener("click", handleDismiss, { once: true });
document.addEventListener("contextmenu", handleDismiss, { once: true });
}, 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", handleDismiss);
document.removeEventListener("contextmenu", handleDismiss);
};
});
$effect(() => {
if (!contextMenu) return;
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
contextMenu = null;
}
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="worktree-file-editor" onkeydown={handleKeyDown}>
<div class="file-toolbar">
<div class="file-toolbar-title">
{path.split("/").pop() ?? path}
{#if isDirty}<span class="dirty-dot" title="Unsaved changes"></span>{/if}
</div>
<div class="file-toolbar-actions">
{#if isDirty}
<button
class="file-toolbar-action save-icon-btn"
onclick={handleSave}
disabled={file?.saving}
title={file?.saving ? "Saving…" : "Save (⌘S)"}
aria-label="Save"
>
<AppIcon name="save" size={14} />
</button>
{/if}
{#each titleMenuItems as item (`${item.command}:${item.group ?? ""}`)}
<button class="file-toolbar-action" onclick={() => openContributedCommand(item.command)}>
{item.title}
</button>
{/each}
</div>
</div>
{#if file?.diskChanged}
<div class="disk-changed-banner">
<AppIcon name="triangle-alert" size={14} />
<span>File changed on disk</span>
<button class="disk-action" onclick={handleLoadFromDisk}>Load from disk</button>
{#if isDirty}
<button class="disk-action disk-action-overwrite" onclick={handleOverwriteDisk}>Overwrite disk</button>
{/if}
</div>
{/if}
{#if file?.loading && !file.content}
<div class="file-state">Loading file...</div>
{:else if unsupportedContent}
<div class="file-unsupported">
<div class="file-unsupported-icon">
<AppIcon name="file-x" size={20} />
</div>
<span class="file-unsupported-label">Binary file</span>
<span class="file-unsupported-sub">This file cannot be displayed in the text editor.</span>
</div>
{:else if file?.error}
<div class="file-error">{file.error}</div>
{:else if file?.content !== null}
{#if isMarkdown}
<MarkdownFileViewer
content={file?.editedContent ?? file?.content ?? ""}
onchange={(value) => explorer.setEditedContent(path, value)}
/>
{:else}
<div
class="file-editor-shell"
role="application"
aria-label={`Editor for ${path}`}
oncontextmenu={handleEditorContextMenu}
>
<MonacoEditor
value={file?.editedContent ?? file?.content ?? ""}
language={explorer.languageIdForPath(path)}
readOnly={false}
onchange={(value) => explorer.setEditedContent(path, value)}
revealLine={line}
revealColumn={column}
height="100%"
file={{ worktree, path }}
/>
</div>
{/if}
{:else}
<div class="file-state">Select a file from the explorer.</div>
{/if}
</div>
{#if contextMenu}
<div
class="file-context-menu"
use:placeMenu={contextMenu}
>
{#each contextMenuItems as item (`${item.command}:${item.group ?? ""}`)}
<button class="file-context-menu-item" onclick={() => openContributedCommand(item.command)}>
{item.title}
</button>
{/each}
</div>
{/if}
<style>
.worktree-file-editor {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--vscode-tab-activeBackground);
}
.file-toolbar {
height: var(--wb-control-size-40);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wb-spacing-12);
padding: 0 var(--wb-spacing-12);
border-bottom: 1px solid var(--vscode-panel-border);
background: color-mix(in srgb, var(--vscode-sideBar-background) 96%, black 4%);
flex-shrink: 0;
}
.file-toolbar-title {
min-width: 0;
font-size: var(--wb-font-size-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
}
.dirty-dot {
width: var(--wb-control-size-8);
height: var(--wb-control-size-8);
border-radius: 50%;
background: var(--vscode-gitDecoration-modifiedResourceForeground, #e2c08d);
flex-shrink: 0;
}
.save-icon-btn {
width: var(--wb-control-size-26);
height: var(--wb-control-size-26);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: var(--wsb-radius-sm);
border: 1px solid transparent;
background: transparent;
color: var(--vscode-gitDecoration-modifiedResourceForeground, #e2c08d);
}
.save-icon-btn:hover:not(:disabled) {
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-widget-border);
}
.save-icon-btn:disabled {
opacity: 0.4;
cursor: default;
}
.disk-changed-banner {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-6) var(--wb-spacing-12);
font-size: var(--wb-font-size-12);
background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground, #352a05) 80%, transparent 20%);
border-bottom: 1px solid var(--vscode-inputValidation-warningBorder, #b89500);
color: var(--vscode-inputValidation-warningForeground, var(--vscode-editor-foreground));
flex-shrink: 0;
}
.disk-action {
padding: var(--wb-spacing-3) var(--wb-spacing-8);
border-radius: var(--wsb-radius-sm);
border: 1px solid currentColor;
background: transparent;
color: inherit;
font-size: var(--wb-font-size-11);
cursor: pointer;
}
.disk-action:hover {
background: color-mix(in srgb, currentColor 15%, transparent 85%);
}
.disk-action-overwrite {
color: var(--vscode-errorForeground);
border-color: var(--vscode-errorForeground);
}
.file-toolbar-actions {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
.file-toolbar-action {
padding: var(--wb-spacing-6) var(--wb-spacing-10);
border-radius: var(--wsb-radius-sm);
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-tab-activeBackground);
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-12);
}
.file-toolbar-action:hover {
background: var(--vscode-list-hoverBackground);
}
.file-state,
.file-error {
padding: var(--wb-spacing-16);
font-size: var(--wb-font-size-12);
}
.file-state {
color: var(--vscode-disabledForeground);
}
.file-unsupported {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--wb-spacing-8);
}
.file-unsupported-icon {
width: var(--wb-control-size-36);
height: var(--wb-control-size-36);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--wb-corner-radius-8);
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-widget-border);
color: var(--vscode-disabledForeground);
margin-bottom: var(--wb-spacing-4);
}
.file-unsupported-label {
font-size: var(--wb-font-size-13);
font-weight: 500;
color: var(--vscode-foreground);
}
.file-unsupported-sub {
font-size: var(--wb-font-size-12);
color: var(--vscode-disabledForeground);
}
.file-error {
color: var(--vscode-errorForeground);
}
.file-editor-shell {
flex: 1;
min-height: 0;
position: relative;
}
.file-context-menu {
position: fixed;
min-width: 220px;
padding: var(--wb-spacing-6);
display: flex;
flex-direction: column;
gap: var(--wb-spacing-4);
border-radius: var(--wsb-radius-md);
border: 1px solid var(--vscode-panel-border);
background: color-mix(in srgb, var(--vscode-sideBar-background) 97%, black 3%);
box-shadow: var(--wb-shadow-md);
z-index: 70;
}
.file-context-menu-item {
width: 100%;
padding: var(--wb-spacing-8) var(--wb-spacing-10);
text-align: left;
border-radius: var(--wsb-radius-sm);
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-12);
}
.file-context-menu-item:hover {
background: var(--vscode-list-hoverBackground);
}
:global(.worktree-file-editor .monaco-shell) {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
}
</style>