mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
feat(plugins): file panels (#7411)
* fix(files): report document MIME types for binary files
File reads now carry the document's MIME type for binary files, so a file panel can
hand the bytes to a viewer that needs the real type (application/pdf).
* feat(plugins): file panels
Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the
file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor,
which claims every text file and is always the last candidate. The panel is the tab body;
the bar shows the document's status and a switcher between the file's panels (side by side
up to three, two on a phone, a menu beyond that).
Paseo asks which panel opens a file only when a plugin panel is among several candidates,
once per open, and remembers the answer per extension or exact filename when told to. Open
with in the tab menu and the switcher change the tab's panel; Always use remembers it.
Opening a file at a line switches the tab to Source.
Panels read and write the host's document, so dirty state, autosave and conflicts stay
Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer.
Replaces the file tab's Preview/Source mode toggle.
* feat(file-panels): choose and forget file panels in Settings > Files
Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.
* docs(plugins): file panel examples and author reference
Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.
* feat(file-panels): panels read and write through a file handle
The file tab stats the file and mounts its panel without reading it. Every
panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url,
version-checked writeText/writeBytes (PluginFileConflictError unless force),
and watch. useFileText, useFileBytes and useFileUrl read through it, and
Loading joins @getpaseo/plugin/client/ui. Panels own their loading state.
The host document pipeline, the explorer preview cache and the preview
lifecycle are deleted. The daemon classifies a file version from a bounded
sniff and writes base64 content as bytes behind fileByteWrites.
A deleted file keeps its panel mounted; a read confirms the deletion first,
so a remove-and-rewrite is not reported as one. Native Source decodes with
builtins and highlights in idle-time slices, the lines around the opening
line first, so a large file no longer blocks the JS thread.
* fix(file-panels): Settings offers Source where the picker does, and review follow-ups
- Settings > Files offers Source on every key except those a built-in panel of
another kind (Image) claims, so a plugin that alone claims a text extension
shows the same choices as the picker.
- Delete the unused isSourceFilePanel.
- The Markdown example warns with the file name when its unmount write fails,
rather than dropping the edit silently.
- Document that PluginFile stat fields are read at render time and that watch
or the read hooks are how a panel learns they changed.
* fix(plugins): file events are objects, deletion is a typed error, DocumentFrame owns its fallback
- watch listeners receive { type: "changed" } | { type: "deleted" }, so later
versions can add fields without breaking them.
- PluginFileDeletedError joins PluginFileConflictError. The read hooks report a
deleted file with it, and a write over a deleted file rejects with it; Source
shows that as the deleted notice. PluginFileConflictError now means only
"changed on disk".
- DocumentFrame on Android shows Paseo's "<TYPE> files can't be shown on this
device" message for documents its WebView cannot display, so the PDF example
is useFileUrl + Loading + DocumentFrame with no platform branch.
* test(file-panels): Settings offers Source for a remembered panel no host has
* test(e2e): hide the developer error toast instead of clicking it away
The locator handler that dismissed #error-toast could match a toast that closed before its
click, then wait on it until the test timed out (agent-timeline-resume.spec.ts:45 failed every
attempt on this branch). Hiding the toast with CSS keeps it off app actions with no race.
* test(e2e): desktop browser-tabs hides developer toasts on its attached page
This commit is contained in:
@@ -56,10 +56,7 @@ export type { DraftInput } from "@/stores/draft-store";
|
||||
export type {
|
||||
Agent,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
ExplorerEntryKind,
|
||||
ExplorerFileKind,
|
||||
ExplorerEncoding,
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { explorerFileFromReadResult } from "./read-result";
|
||||
|
||||
function textRead(bytes: Uint8Array): FileReadResult {
|
||||
return {
|
||||
bytes,
|
||||
mime: "text/plain",
|
||||
size: bytes.byteLength,
|
||||
path: "notes.txt",
|
||||
kind: "text",
|
||||
modifiedAt: "2026-07-21T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("explorerFileFromReadResult", () => {
|
||||
it("records and hides a leading UTF-8 BOM", () => {
|
||||
const file = explorerFileFromReadResult(
|
||||
textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
|
||||
);
|
||||
|
||||
expect(file).toMatchObject({ content: "hi", hasBom: true });
|
||||
});
|
||||
|
||||
it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => {
|
||||
const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi")));
|
||||
const embedded = explorerFileFromReadResult(
|
||||
textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])),
|
||||
);
|
||||
|
||||
expect(plain.hasBom).toBe(false);
|
||||
expect(embedded.hasBom).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
|
||||
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
|
||||
const isText = file.kind === "text";
|
||||
return {
|
||||
path: file.path,
|
||||
kind: file.kind,
|
||||
encoding: isText ? "utf-8" : "none",
|
||||
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
|
||||
hasBom: isText && hasUtf8Bom(file.bytes),
|
||||
mimeType: file.mime,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
};
|
||||
}
|
||||
|
||||
function hasUtf8Bom(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
|
||||
}
|
||||
@@ -1,126 +1,42 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
import {
|
||||
FilePanelBarMenu,
|
||||
FilePanelSwitcher,
|
||||
useFilePanelChoiceSnapshot,
|
||||
type FilePanelChoice,
|
||||
} from "@/file-panels";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export function FilePanelBar({
|
||||
size,
|
||||
lineCount,
|
||||
mode,
|
||||
onModeChange,
|
||||
editorStatus,
|
||||
cursor,
|
||||
vimMode,
|
||||
conflict,
|
||||
}: {
|
||||
size: number;
|
||||
lineCount?: number;
|
||||
mode?: "preview" | "source";
|
||||
onModeChange?(mode: "preview" | "source"): void;
|
||||
editorStatus?: FileEditorStatus;
|
||||
cursor?: { line: number; column: number };
|
||||
vimMode?: string | null;
|
||||
conflict?: FileConflictAlertState;
|
||||
}) {
|
||||
/**
|
||||
* The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel
|
||||
* has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest.
|
||||
*/
|
||||
export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const previewModes = [
|
||||
{
|
||||
value: "preview" as const,
|
||||
label: t("panels.file.editor.preview"),
|
||||
testID: "file-mode-preview",
|
||||
},
|
||||
{ value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" },
|
||||
];
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
if (!openWith) return null;
|
||||
return (
|
||||
<View style={styles.chrome}>
|
||||
<PaneContentToolbar testID="file-panel-bar">
|
||||
<View style={styles.row}>
|
||||
<View style={styles.metadata}>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||
>
|
||||
{formatFileSize(size)}
|
||||
</Text>
|
||||
{lineCount !== undefined ? (
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||
>
|
||||
{t("panels.file.editor.lines", { count: lineCount })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityLabel={
|
||||
editorStatus
|
||||
? t("panels.file.editor.editorStatus", { status: editorStatus })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{editorStatus === "dirty" ? (
|
||||
<View
|
||||
style={styles.dirtyDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
/>
|
||||
) : null}
|
||||
{editorStatus === "saving" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{editorStatus === "error" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||
) : null}
|
||||
{vimMode ? (
|
||||
<Text
|
||||
style={styles.vim}
|
||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||
>
|
||||
{vimMode}
|
||||
</Text>
|
||||
) : null}
|
||||
{cursor ? (
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
accessibilityLabel={t("panels.file.editor.cursor", cursor)}
|
||||
>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{mode && onModeChange ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={mode}
|
||||
onValueChange={onModeChange}
|
||||
testID="file-preview-mode"
|
||||
options={previewModes}
|
||||
<View style={styles.spacer} />
|
||||
{modified ? (
|
||||
<View
|
||||
style={styles.modifiedDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
testID="file-panel-modified"
|
||||
/>
|
||||
) : null}
|
||||
<FilePanelSwitcher choice={choice} />
|
||||
<FilePanelBarMenu choice={choice} />
|
||||
</View>
|
||||
</PaneContentToolbar>
|
||||
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
chrome: {
|
||||
flexShrink: 0,
|
||||
@@ -132,31 +48,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
metadata: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
|
||||
dirtyDot: {
|
||||
spacer: { flex: 1 },
|
||||
modifiedDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
vim: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
FileEditorModel,
|
||||
getFileConflictCallout,
|
||||
type FileEditorClock,
|
||||
type FileEditorFile,
|
||||
type FileEditorObservation,
|
||||
type FileEditorSession,
|
||||
type FileObservationSource,
|
||||
} from "./model";
|
||||
|
||||
class TestClock implements FileEditorClock {
|
||||
private callback: (() => void) | null = null;
|
||||
|
||||
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
||||
this.callback = callback;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
clearTimeout(): void {
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
const callback = this.callback;
|
||||
this.callback = null;
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
class FileSession implements FileEditorSession {
|
||||
file: FileEditorFile;
|
||||
writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = [];
|
||||
nextWrite: FileWriteResult | Error | null = null;
|
||||
private pendingWrite: Promise<FileWriteResult> | null = null;
|
||||
private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null;
|
||||
|
||||
constructor(file: FileEditorFile) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
async write(input: {
|
||||
content: string;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult> {
|
||||
this.writes.push(input);
|
||||
if (this.pendingWrite) return this.pendingWrite;
|
||||
if (this.nextWrite instanceof Error) throw this.nextWrite;
|
||||
if (this.nextWrite) return this.nextWrite;
|
||||
return {
|
||||
status: "written",
|
||||
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
size: input.content.length,
|
||||
};
|
||||
}
|
||||
|
||||
holdNextWrite(): void {
|
||||
this.pendingWrite = new Promise((resolve) => {
|
||||
this.resolvePendingWrite = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
finishHeldWrite(result: FileWriteResult): void {
|
||||
this.resolvePendingWrite?.(result);
|
||||
this.pendingWrite = null;
|
||||
this.resolvePendingWrite = null;
|
||||
}
|
||||
}
|
||||
|
||||
class ObservationSource implements FileObservationSource {
|
||||
observation: FileEditorObservation | null;
|
||||
refreshes = 0;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(observation: FileEditorObservation | null) {
|
||||
this.observation = observation;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
getObservation(): FileEditorObservation | null {
|
||||
return this.observation;
|
||||
}
|
||||
|
||||
refresh = (): void => {
|
||||
this.refreshes += 1;
|
||||
};
|
||||
|
||||
emit(observation: FileEditorObservation): void {
|
||||
this.observation = observation;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function ready(
|
||||
modifiedAt = "2026-07-18T00:00:00.000Z",
|
||||
size = 3,
|
||||
): Extract<FileVersion, { status: "ready" }> {
|
||||
return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt };
|
||||
}
|
||||
|
||||
interface MakeModelInput {
|
||||
content?: string;
|
||||
hasBom?: boolean;
|
||||
}
|
||||
|
||||
function makeModel(input: MakeModelInput = {}) {
|
||||
const file = {
|
||||
content: input.content ?? "one",
|
||||
hasBom: input.hasBom ?? false,
|
||||
version: ready() as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
const session = new FileSession(file);
|
||||
const clock = new TestClock();
|
||||
return { model: new FileEditorModel({ file, session, clock }), session, clock };
|
||||
}
|
||||
|
||||
function observeFile(model: FileEditorModel, file: FileEditorFile): void {
|
||||
model.receiveFileObservation({ status: "ready", file });
|
||||
}
|
||||
|
||||
function observeVersion(model: FileEditorModel, version: FileEditorObservation): void {
|
||||
model.receiveFileObservation(version);
|
||||
}
|
||||
|
||||
describe("FileEditorModel", () => {
|
||||
test("tracks whether the current buffer differs from persisted content", async () => {
|
||||
const { model } = makeModel();
|
||||
|
||||
expect(model.getSnapshot().modified).toBe(false);
|
||||
model.edit("two");
|
||||
expect(model.getSnapshot().modified).toBe(true);
|
||||
model.edit("one");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
|
||||
model.edit("saved");
|
||||
await model.save();
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
});
|
||||
|
||||
test("adopts a precise revision for otherwise unchanged initial metadata", () => {
|
||||
const { model } = makeModel();
|
||||
|
||||
observeFile(model, {
|
||||
content: "one",
|
||||
hasBom: false,
|
||||
version: { ...ready(), revision: "precise-revision" },
|
||||
});
|
||||
|
||||
expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" });
|
||||
});
|
||||
|
||||
test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
observeFile(model, {
|
||||
content: "one",
|
||||
hasBom: false,
|
||||
version: { ...ready(), revision: "replacement-revision" },
|
||||
});
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" });
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "local",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
expectedRevision: "replacement-revision",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("adopts an external BOM change before saving a dirty buffer", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
observeFile(model, {
|
||||
content: "one",
|
||||
hasBom: true,
|
||||
version: { ...ready(), revision: "replacement-revision" },
|
||||
});
|
||||
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "\uFEFFlocal",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
expectedRevision: "replacement-revision",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("conflicts when a same-content observation changes the BOM during a save", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.holdNextWrite();
|
||||
model.edit("saved");
|
||||
const save = model.save();
|
||||
observeFile(model, {
|
||||
content: "saved",
|
||||
hasBom: true,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 6),
|
||||
});
|
||||
session.finishHeldWrite({
|
||||
status: "written",
|
||||
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
size: 5,
|
||||
});
|
||||
|
||||
await save;
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "conflict",
|
||||
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores a settled observation that was already consumed", () => {
|
||||
const { model } = makeModel();
|
||||
const observation: FileEditorObservation = {
|
||||
status: "ready",
|
||||
file: { content: "one", hasBom: false, version: ready() },
|
||||
};
|
||||
let emissions = 0;
|
||||
model.subscribe(() => {
|
||||
emissions += 1;
|
||||
});
|
||||
model.receiveFileObservation(observation);
|
||||
const emissionsAfterFirstDelivery = emissions;
|
||||
|
||||
model.receiveFileObservation(observation);
|
||||
|
||||
expect(emissions).toBe(emissionsAfterFirstDelivery);
|
||||
});
|
||||
|
||||
test("does not replay the pre-save observation while its refresh is pending", async () => {
|
||||
const { model } = makeModel();
|
||||
const observation: FileEditorObservation = {
|
||||
status: "ready",
|
||||
file: { content: "one", hasBom: false, version: ready() },
|
||||
};
|
||||
model.receiveFileObservation(observation);
|
||||
model.edit("saved");
|
||||
await model.save();
|
||||
|
||||
model.receiveFileObservation(observation);
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" });
|
||||
});
|
||||
|
||||
test("does not reload stale bytes after a write conflict", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.nextWrite = {
|
||||
status: "conflict",
|
||||
version: ready("2026-07-18T00:00:02.000Z", 8),
|
||||
};
|
||||
model.edit("important local work");
|
||||
await model.save();
|
||||
|
||||
await model.reload();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "conflict",
|
||||
content: "important local work",
|
||||
observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||
});
|
||||
});
|
||||
|
||||
test("reloads a write conflict only after refreshed bytes arrive", async () => {
|
||||
const { model, session } = makeModel();
|
||||
const source = new ObservationSource({
|
||||
status: "ready",
|
||||
file: { content: "one", hasBom: false, version: ready() },
|
||||
});
|
||||
model.connectFileObservations(source);
|
||||
session.nextWrite = {
|
||||
status: "conflict",
|
||||
version: ready("2026-07-18T00:00:02.000Z", 4),
|
||||
};
|
||||
model.edit("local");
|
||||
await model.save();
|
||||
|
||||
await model.reload();
|
||||
expect(source.refreshes).toBe(1);
|
||||
expect(model.getSnapshot().content).toBe("local");
|
||||
source.emit({
|
||||
status: "ready",
|
||||
file: {
|
||||
content: "disk",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 4),
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||
});
|
||||
|
||||
test("abandons a deferred reload when its refresh fails", async () => {
|
||||
const { model, session } = makeModel();
|
||||
const source = new ObservationSource({
|
||||
status: "ready",
|
||||
file: { content: "one", hasBom: false, version: ready() },
|
||||
});
|
||||
model.connectFileObservations(source);
|
||||
session.nextWrite = {
|
||||
status: "conflict",
|
||||
version: ready("2026-07-18T00:00:02.000Z", 4),
|
||||
};
|
||||
model.edit("local");
|
||||
await model.save();
|
||||
await model.reload();
|
||||
source.emit({
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "File unavailable.",
|
||||
});
|
||||
model.edit("new local work");
|
||||
|
||||
source.emit({
|
||||
status: "ready",
|
||||
file: {
|
||||
content: "disk",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:03.000Z", 4),
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" });
|
||||
});
|
||||
|
||||
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.holdNextWrite();
|
||||
model.edit("saving");
|
||||
|
||||
const save = model.save();
|
||||
model.edit("newer edit");
|
||||
session.finishHeldWrite({
|
||||
status: "written",
|
||||
modifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
size: 6,
|
||||
});
|
||||
await save;
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "dirty",
|
||||
content: "newer edit",
|
||||
modified: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("autosaves the latest edit after inactivity", async () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
|
||||
model.edit("two");
|
||||
model.edit("three");
|
||||
clock.fire();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{ content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
|
||||
]);
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("keeps CRLF content in file form", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\ntwo\r\n" });
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
content: "one\r\ntwo\r\n",
|
||||
lineSeparator: "\r\n",
|
||||
});
|
||||
model.edit("one\r\ntwo\r\nthree\r\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "one\r\ntwo\r\nthree\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("restores a UTF-8 BOM before writing a CRLF file", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
|
||||
model.edit("saved\r\n");
|
||||
await model.save();
|
||||
model.edit("saved again\r\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "\uFEFFsaved\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
content: "\uFEFFsaved again\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:01.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the first line separator when a file mixes styles", () => {
|
||||
const { model } = makeModel({ content: "one\r\ntwo\nthree\r" });
|
||||
|
||||
expect(model.getSnapshot().lineSeparator).toBe("\r\n");
|
||||
});
|
||||
|
||||
test("reloads a clean editor when the disk version changes", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.file = {
|
||||
content: "external",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 8) as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
|
||||
observeFile(model, session.file);
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" });
|
||||
});
|
||||
|
||||
test("adopts the format from a clean remote refresh", async () => {
|
||||
const { model, session } = makeModel({ content: "local\r\n", hasBom: true });
|
||||
session.file = {
|
||||
content: "remote\n",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 7) as Extract<FileVersion, { status: "ready" }>,
|
||||
};
|
||||
|
||||
observeFile(model, session.file);
|
||||
expect(model.getSnapshot().lineSeparator).toBe("\n");
|
||||
model.edit("saved\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes.at(-1)).toEqual({
|
||||
content: "saved\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("applies consecutive clean disk observations", () => {
|
||||
const { model } = makeModel();
|
||||
const firstVersion = ready("2026-07-18T00:00:02.000Z", 5);
|
||||
const latestVersion = ready("2026-07-18T00:00:03.000Z", 6);
|
||||
|
||||
observeFile(model, { content: "first", hasBom: false, version: firstVersion });
|
||||
observeFile(model, { content: "latest", hasBom: false, version: latestVersion });
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" });
|
||||
});
|
||||
|
||||
test("preserves a dirty buffer and overwrites against the newest disk revision", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
observeFile(model, {
|
||||
content: "disk",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 4),
|
||||
});
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" });
|
||||
await model.overwrite();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" },
|
||||
]);
|
||||
expect(model.getSnapshot().status).toBe("clean");
|
||||
});
|
||||
|
||||
test("keeps the local CRLF and BOM when overwriting a conflict", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
model.edit("local\r\n");
|
||||
observeFile(model, {
|
||||
content: "disk",
|
||||
hasBom: false,
|
||||
version: ready("2026-07-18T00:00:02.000Z", 4),
|
||||
});
|
||||
|
||||
await model.overwrite();
|
||||
|
||||
expect(session.writes).toEqual([
|
||||
{
|
||||
content: "\uFEFFlocal\r\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("reload discards a conflicted local buffer for the disk contents", async () => {
|
||||
const { model, session } = makeModel();
|
||||
model.edit("local");
|
||||
const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract<
|
||||
FileVersion,
|
||||
{ status: "ready" }
|
||||
>;
|
||||
session.file = { content: "disk", hasBom: false, version: diskVersion };
|
||||
observeFile(model, session.file);
|
||||
|
||||
await model.reload();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" });
|
||||
});
|
||||
|
||||
test("adopts the remote format when reloading a conflict", async () => {
|
||||
const { model, session } = makeModel({ content: "one\r\n", hasBom: true });
|
||||
model.edit("local\r\n");
|
||||
const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract<
|
||||
FileVersion,
|
||||
{ status: "ready" }
|
||||
>;
|
||||
session.file = { content: "disk\n", hasBom: false, version: diskVersion };
|
||||
observeFile(model, session.file);
|
||||
|
||||
await model.reload();
|
||||
model.edit("saved\n");
|
||||
await model.save();
|
||||
|
||||
expect(session.writes.at(-1)).toEqual({
|
||||
content: "saved\n",
|
||||
expectedModifiedAt: "2026-07-18T00:00:02.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports failed saves without losing the local buffer", async () => {
|
||||
const { model, session } = makeModel();
|
||||
session.nextWrite = new Error("disk full");
|
||||
model.edit("important local work");
|
||||
|
||||
await model.save();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "error",
|
||||
content: "important local work",
|
||||
error: "disk full",
|
||||
});
|
||||
});
|
||||
|
||||
test("a deletion conflicts with local changes and stops autosave", () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
|
||||
clock.fire();
|
||||
|
||||
expect(model.getSnapshot().status).toBe("conflict");
|
||||
expect(session.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("clears a transient check error when the recovered file is unchanged", () => {
|
||||
const { model } = makeModel();
|
||||
observeVersion(model, {
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "Requested path is not a file",
|
||||
});
|
||||
|
||||
observeFile(model, { content: "one", hasBom: false, version: ready() });
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "clean",
|
||||
modified: false,
|
||||
observedVersion: { status: "ready" },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resumes autosave when a dirty file recovers unchanged from a check error", async () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
observeVersion(model, {
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "Requested path is not a file",
|
||||
});
|
||||
|
||||
observeFile(model, { content: "one", hasBom: false, version: ready() });
|
||||
clock.fire();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
expect(session.writes).toEqual([
|
||||
{ content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("dispose cancels pending autosave", () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
|
||||
model.dispose();
|
||||
clock.fire();
|
||||
|
||||
expect(session.writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("suspends a pending autosave while close confirmation is active", async () => {
|
||||
const { model, session, clock } = makeModel();
|
||||
model.edit("local");
|
||||
|
||||
const resume = model.suspendAutosave();
|
||||
clock.fire();
|
||||
expect(session.writes).toEqual([]);
|
||||
|
||||
resume();
|
||||
clock.fire();
|
||||
await Promise.resolve();
|
||||
expect(session.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("maps conflict versions to one exhaustive callout state", () => {
|
||||
const { model } = makeModel();
|
||||
const snapshot = model.getSnapshot();
|
||||
|
||||
expect(getFileConflictCallout(snapshot)).toBeNull();
|
||||
expect(
|
||||
getFileConflictCallout({
|
||||
...snapshot,
|
||||
status: "conflict",
|
||||
modified: true,
|
||||
observedVersion: ready("2026-07-18T00:00:01.000Z"),
|
||||
}),
|
||||
).toEqual({ kind: "changed", canOverwrite: true });
|
||||
expect(
|
||||
getFileConflictCallout({
|
||||
...snapshot,
|
||||
status: "conflict",
|
||||
observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" },
|
||||
}),
|
||||
).toEqual({ kind: "deleted" });
|
||||
expect(
|
||||
getFileConflictCallout({
|
||||
...snapshot,
|
||||
status: "conflict",
|
||||
observedVersion: {
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "unreadable",
|
||||
},
|
||||
}),
|
||||
).toEqual({ kind: "checkFailed" });
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
export type FileLineSeparator = "\n" | "\r\n" | "\r";
|
||||
|
||||
export interface FileEditorSnapshot {
|
||||
status: FileEditorStatus;
|
||||
content: string;
|
||||
lineSeparator: FileLineSeparator;
|
||||
modified: boolean;
|
||||
version: FileVersion;
|
||||
observedVersion: FileVersion;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type FileConflictCallout =
|
||||
| { kind: "changed"; canOverwrite: boolean }
|
||||
| { kind: "deleted" }
|
||||
| { kind: "checkFailed" };
|
||||
|
||||
export interface FileEditorFile {
|
||||
content: string;
|
||||
hasBom: boolean;
|
||||
version: Extract<FileVersion, { status: "ready" }>;
|
||||
}
|
||||
|
||||
export interface FileEditorSession {
|
||||
write(input: {
|
||||
content: string;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult>;
|
||||
}
|
||||
|
||||
export type FileEditorObservation =
|
||||
| { status: "ready"; file: FileEditorFile }
|
||||
| Extract<FileVersion, { status: "missing" | "error" }>;
|
||||
|
||||
export interface FileObservationSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getObservation(): FileEditorObservation | null;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion };
|
||||
|
||||
export interface FileEditorClock {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||
}
|
||||
|
||||
const systemClock: FileEditorClock = {
|
||||
setTimeout(callback, delay) {
|
||||
return globalThis.setTimeout(callback, delay);
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
globalThis.clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
|
||||
export class FileEditorModel {
|
||||
private readonly session: FileEditorSession;
|
||||
private readonly clock: FileEditorClock;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FileEditorSnapshot;
|
||||
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||
private saveSequence = 0;
|
||||
private disposed = false;
|
||||
private observedWhileSaving: FileEditorObservation | null = null;
|
||||
private observed: ObservedDiskState;
|
||||
private lastReceivedObservation: FileEditorObservation | null = null;
|
||||
private refreshObservation: (() => void) | null = null;
|
||||
private reloadRequested = false;
|
||||
private persistedContent: string;
|
||||
private hasBom: boolean;
|
||||
private unsubscribeObservationSource: (() => void) | null = null;
|
||||
|
||||
constructor(input: {
|
||||
file: FileEditorFile;
|
||||
session: FileEditorSession;
|
||||
clock?: FileEditorClock;
|
||||
}) {
|
||||
this.session = input.session;
|
||||
this.clock = input.clock ?? systemClock;
|
||||
this.persistedContent = input.file.content;
|
||||
this.hasBom = input.file.hasBom;
|
||||
this.observed = { status: "ready", file: input.file };
|
||||
this.snapshot = {
|
||||
status: "clean",
|
||||
content: input.file.content,
|
||||
lineSeparator: detectLineSeparator(input.file.content),
|
||||
modified: false,
|
||||
version: input.file.version,
|
||||
observedVersion: input.file.version,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||
|
||||
connectFileObservations(source: FileObservationSource): void {
|
||||
this.disconnectFileObservations();
|
||||
this.refreshObservation = source.refresh;
|
||||
const receiveObservation = () => {
|
||||
const observation = source.getObservation();
|
||||
if (observation) this.receiveFileObservation(observation);
|
||||
};
|
||||
this.unsubscribeObservationSource = source.subscribe(receiveObservation);
|
||||
receiveObservation();
|
||||
}
|
||||
|
||||
disconnectFileObservations(): void {
|
||||
this.unsubscribeObservationSource?.();
|
||||
this.unsubscribeObservationSource = null;
|
||||
this.refreshObservation = null;
|
||||
}
|
||||
|
||||
edit(content: string): void {
|
||||
if (this.disposed || content === this.snapshot.content) return;
|
||||
this.reloadRequested = false;
|
||||
const modified = content !== this.persistedContent;
|
||||
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||
if (this.snapshot.status === "conflict") {
|
||||
status = "conflict";
|
||||
}
|
||||
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||
if (status === "dirty") this.scheduleAutosave();
|
||||
else this.clearAutosave();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) {
|
||||
return;
|
||||
}
|
||||
if (this.snapshot.observedVersion.status !== "ready") {
|
||||
this.enterConflict(this.snapshot.observedVersion);
|
||||
return;
|
||||
}
|
||||
await this.performWrite(this.snapshot.observedVersion);
|
||||
}
|
||||
|
||||
receiveFileObservation(observation: FileEditorObservation): void {
|
||||
if (this.disposed || observation === this.lastReceivedObservation) return;
|
||||
this.lastReceivedObservation = observation;
|
||||
const version = observationVersion(observation);
|
||||
this.observed = observation;
|
||||
this.setSnapshot({ ...this.snapshot, observedVersion: version });
|
||||
if (this.snapshot.status === "saving") {
|
||||
this.observedWhileSaving = observation;
|
||||
return;
|
||||
}
|
||||
if (observation.status !== "ready") {
|
||||
this.reloadRequested = false;
|
||||
this.enterConflict(version);
|
||||
return;
|
||||
}
|
||||
if (this.reloadRequested) {
|
||||
this.reloadRequested = false;
|
||||
this.applyFile(observation.file);
|
||||
return;
|
||||
}
|
||||
if (observation.file.content === this.persistedContent) {
|
||||
this.adoptUnchangedFile(observation.file);
|
||||
return;
|
||||
}
|
||||
if (this.snapshot.status === "clean") {
|
||||
this.applyFile(observation.file);
|
||||
return;
|
||||
}
|
||||
this.enterConflict(version);
|
||||
}
|
||||
|
||||
async overwrite(): Promise<void> {
|
||||
if (this.disposed || this.snapshot.status !== "conflict") return;
|
||||
if (this.snapshot.observedVersion.status !== "ready") return;
|
||||
await this.performWrite(this.snapshot.observedVersion);
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.observed.status !== "ready") {
|
||||
this.reloadRequested = true;
|
||||
this.refreshObservation?.();
|
||||
return;
|
||||
}
|
||||
this.applyFile(this.observed.file);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.reloadRequested = false;
|
||||
this.saveSequence += 1;
|
||||
this.clearAutosave();
|
||||
this.disconnectFileObservations();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
suspendAutosave(): () => void {
|
||||
const wasScheduled = this.autosave !== null;
|
||||
this.clearAutosave();
|
||||
let resumed = false;
|
||||
return () => {
|
||||
if (resumed || this.disposed) return;
|
||||
resumed = true;
|
||||
if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave();
|
||||
};
|
||||
}
|
||||
|
||||
private async performWrite(
|
||||
expectedVersion: Extract<FileVersion, { status: "ready" }>,
|
||||
): Promise<void> {
|
||||
this.clearAutosave();
|
||||
const sequence = ++this.saveSequence;
|
||||
const content = this.snapshot.content;
|
||||
const hasBom = this.hasBom;
|
||||
this.observedWhileSaving = null;
|
||||
this.setSnapshot({ ...this.snapshot, status: "saving", error: null });
|
||||
const serializedContent = hasBom ? `\uFEFF${content}` : content;
|
||||
let result: FileWriteResult;
|
||||
try {
|
||||
result = await this.session.write({
|
||||
content: serializedContent,
|
||||
expectedModifiedAt: expectedVersion.modifiedAt,
|
||||
expectedRevision: expectedVersion.revision,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
if (result.status === "error") {
|
||||
this.setSnapshot({ ...this.snapshot, status: "error", error: result.error });
|
||||
return;
|
||||
}
|
||||
if (result.status === "conflict") {
|
||||
this.observed = { status: "unsettled", version: result.version };
|
||||
this.enterConflict(result.version);
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenVersion: Extract<FileVersion, { status: "ready" }> = {
|
||||
status: "ready",
|
||||
cwd: this.snapshot.version.cwd,
|
||||
path: this.snapshot.version.path,
|
||||
size: result.size,
|
||||
modifiedAt: result.modifiedAt,
|
||||
revision: result.revision,
|
||||
};
|
||||
const pending = this.takeObservedWhileSaving();
|
||||
this.persistedContent = content;
|
||||
if (pending && !observationMatchesWrite(pending, content, hasBom)) {
|
||||
const pendingVersion = observationVersion(pending);
|
||||
this.observed = pending;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "conflict",
|
||||
modified: this.snapshot.content !== this.persistedContent,
|
||||
version: writtenVersion,
|
||||
observedVersion: pendingVersion,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion;
|
||||
this.observed = pending ?? { status: "unsettled", version: writtenVersion };
|
||||
const modified = this.snapshot.content !== this.persistedContent;
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: modified ? "dirty" : "clean",
|
||||
modified,
|
||||
version: settledVersion,
|
||||
observedVersion: settledVersion,
|
||||
error: null,
|
||||
});
|
||||
if (modified) this.scheduleAutosave();
|
||||
}
|
||||
|
||||
private applyFile(file: FileEditorFile): void {
|
||||
this.clearAutosave();
|
||||
this.saveSequence += 1;
|
||||
this.persistedContent = file.content;
|
||||
this.hasBom = file.hasBom;
|
||||
this.observed = { status: "ready", file };
|
||||
this.setSnapshot({
|
||||
status: "clean",
|
||||
content: file.content,
|
||||
lineSeparator: detectLineSeparator(file.content),
|
||||
modified: false,
|
||||
version: file.version,
|
||||
observedVersion: file.version,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
private takeObservedWhileSaving(): FileEditorObservation | null {
|
||||
const observation = this.observedWhileSaving;
|
||||
this.observedWhileSaving = null;
|
||||
return observation;
|
||||
}
|
||||
|
||||
private enterConflict(version: FileVersion): void {
|
||||
this.clearAutosave();
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status: "conflict",
|
||||
modified: this.snapshot.content !== this.persistedContent,
|
||||
observedVersion: version,
|
||||
error: version.status === "error" ? version.error : null,
|
||||
});
|
||||
}
|
||||
|
||||
private adoptUnchangedFile(file: FileEditorFile): void {
|
||||
this.hasBom = file.hasBom;
|
||||
this.observed = { status: "ready", file };
|
||||
const modified = this.snapshot.content !== this.persistedContent;
|
||||
const recovering = this.snapshot.status === "conflict";
|
||||
let status = this.snapshot.status;
|
||||
if (recovering) status = modified ? "dirty" : "clean";
|
||||
this.setSnapshot({
|
||||
...this.snapshot,
|
||||
status,
|
||||
modified,
|
||||
version: file.version,
|
||||
observedVersion: file.version,
|
||||
error: recovering ? null : this.snapshot.error,
|
||||
});
|
||||
if (status === "dirty") this.scheduleAutosave();
|
||||
else this.clearAutosave();
|
||||
}
|
||||
|
||||
private scheduleAutosave(): void {
|
||||
this.clearAutosave();
|
||||
this.autosave = this.clock.setTimeout(() => {
|
||||
this.autosave = null;
|
||||
void this.save();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
private clearAutosave(): void {
|
||||
if (!this.autosave) return;
|
||||
this.clock.clearTimeout(this.autosave);
|
||||
this.autosave = null;
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: FileEditorSnapshot): void {
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
|
||||
if (snapshot.status !== "conflict") return null;
|
||||
switch (snapshot.observedVersion.status) {
|
||||
case "ready":
|
||||
return { kind: "changed", canOverwrite: snapshot.modified };
|
||||
case "missing":
|
||||
return { kind: "deleted" };
|
||||
case "error":
|
||||
return { kind: "checkFailed" };
|
||||
default:
|
||||
return assertNever(snapshot.observedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected file version: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function detectLineSeparator(content: string): FileLineSeparator {
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content.charCodeAt(index);
|
||||
if (character === 10) return "\n";
|
||||
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
|
||||
}
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function observationVersion(observation: FileEditorObservation): FileVersion {
|
||||
return observation.status === "ready" ? observation.file.version : observation;
|
||||
}
|
||||
|
||||
function observationMatchesWrite(
|
||||
observation: FileEditorObservation,
|
||||
content: string,
|
||||
hasBom: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
observation.status === "ready" &&
|
||||
observation.file.content === content &&
|
||||
observation.file.hasBom === hasBom
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { LiveFileObservation } from "../live-file/model";
|
||||
import { createFileObservationSource, type LiveObservationSource } from "./observation-source";
|
||||
|
||||
class TestLiveObservationSource implements LiveObservationSource {
|
||||
observation: LiveFileObservation | null;
|
||||
|
||||
constructor(observation: LiveFileObservation | null) {
|
||||
this.observation = observation;
|
||||
}
|
||||
|
||||
subscribe(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
getObservation(): LiveFileObservation | null {
|
||||
return this.observation;
|
||||
}
|
||||
|
||||
refresh(): void {}
|
||||
}
|
||||
|
||||
function readyObservation(): LiveFileObservation {
|
||||
const file: FileReadResult = {
|
||||
bytes: new TextEncoder().encode("one"),
|
||||
mime: "text/plain",
|
||||
size: 3,
|
||||
path: "file.ts",
|
||||
kind: "text",
|
||||
modifiedAt: "2026-07-18T00:00:00.000Z",
|
||||
revision: "revision-1",
|
||||
};
|
||||
return {
|
||||
status: "ready",
|
||||
file,
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFileObservationSource", () => {
|
||||
test("returns the same editor observation until the live observation changes", () => {
|
||||
const liveFile = new TestLiveObservationSource(readyObservation());
|
||||
const source = createFileObservationSource(liveFile);
|
||||
|
||||
const first = source.getObservation();
|
||||
const repeated = source.getObservation();
|
||||
|
||||
expect(repeated).toBe(first);
|
||||
liveFile.observation = readyObservation();
|
||||
expect(source.getObservation()).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { LiveFileObservation } from "../live-file/model";
|
||||
import type { FileEditorObservation, FileObservationSource } from "./model";
|
||||
|
||||
export interface LiveObservationSource {
|
||||
subscribe(listener: () => void): () => void;
|
||||
getObservation(): LiveFileObservation | null;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
export function createFileObservationSource(
|
||||
liveFile: LiveObservationSource,
|
||||
): FileObservationSource {
|
||||
let liveObservation: LiveFileObservation | null = null;
|
||||
let editorObservation: FileEditorObservation | null = null;
|
||||
return {
|
||||
subscribe: liveFile.subscribe,
|
||||
refresh: liveFile.refresh,
|
||||
getObservation() {
|
||||
const nextLiveObservation = liveFile.getObservation();
|
||||
if (nextLiveObservation === liveObservation) return editorObservation;
|
||||
liveObservation = nextLiveObservation;
|
||||
editorObservation = fileEditorObservation(nextLiveObservation);
|
||||
return editorObservation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fileEditorObservation(
|
||||
observation: LiveFileObservation | null,
|
||||
): FileEditorObservation | null {
|
||||
if (!observation || observation.status !== "ready") return observation;
|
||||
const decodedFile = explorerFileFromReadResult(observation.file);
|
||||
if (decodedFile.kind !== "text" || decodedFile.content === undefined) {
|
||||
return {
|
||||
status: "error",
|
||||
cwd: observation.version.cwd,
|
||||
path: observation.version.path,
|
||||
error: "File is no longer text.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "ready",
|
||||
file: {
|
||||
content: decodedFile.content,
|
||||
hasBom: decodedFile.hasBom,
|
||||
version: observation.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { LiveFileModel, type LiveFileSession } from "./model";
|
||||
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function useLiveFile(input: {
|
||||
client: DaemonClient | null;
|
||||
cwd: string | null;
|
||||
path: string | null;
|
||||
enabled: boolean;
|
||||
liveUpdates: boolean;
|
||||
}) {
|
||||
const [model] = useState(() => new LiveFileModel());
|
||||
const session = useMemo<LiveFileSession | null>(() => {
|
||||
if (!input.client) return null;
|
||||
const client = input.client;
|
||||
return {
|
||||
subscribe(target, onVersion) {
|
||||
return client.subscribeFile(target, onVersion);
|
||||
},
|
||||
read(target) {
|
||||
return client.readFile(
|
||||
target.cwd,
|
||||
target.path,
|
||||
undefined,
|
||||
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [input.client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.enabled || !session || !input.cwd || !input.path) {
|
||||
model.close();
|
||||
return;
|
||||
}
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: input.cwd, path: input.path },
|
||||
liveUpdates: input.liveUpdates,
|
||||
});
|
||||
return () => model.close();
|
||||
}, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]);
|
||||
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
return {
|
||||
file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null,
|
||||
error: snapshot.read.status === "error" ? snapshot.read.error : null,
|
||||
// Effects start reads after the first render. An enabled target is therefore
|
||||
// already in the producer's initial/retargeting display state.
|
||||
isFetching:
|
||||
input.enabled &&
|
||||
(snapshot.read.status === "pending" ||
|
||||
(snapshot.read.status === "idle" && snapshot.observation === null)),
|
||||
isRetrying: snapshot.read.status === "pending" && snapshot.read.requested,
|
||||
refresh: model.refresh,
|
||||
model,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function ready(revision = "revision-1"): Extract<FileVersion, { status: "ready" }> {
|
||||
return {
|
||||
status: "ready",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
size: 3,
|
||||
modifiedAt: "2026-07-30T00:00:00.000Z",
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
function file(revision = "revision-1", content = "one"): FileReadResult {
|
||||
return {
|
||||
bytes: new TextEncoder().encode(content),
|
||||
mime: "text/plain",
|
||||
size: content.length,
|
||||
path: "file.ts",
|
||||
kind: "text",
|
||||
modifiedAt: "2026-07-30T00:00:00.000Z",
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
class TestLiveFileSession implements LiveFileSession {
|
||||
readonly reads: Deferred<FileReadResult>[] = [];
|
||||
subscribeAttempts = 0;
|
||||
private onVersion: ((version: FileVersion) => void) | null = null;
|
||||
private readonly initial: FileVersion;
|
||||
|
||||
constructor(initial: FileVersion = ready()) {
|
||||
this.initial = initial;
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
_target: { cwd: string; path: string },
|
||||
onVersion: (version: FileVersion) => void,
|
||||
): Promise<LiveFileSubscription> {
|
||||
this.subscribeAttempts += 1;
|
||||
this.onVersion = onVersion;
|
||||
return { initial: this.initial, unsubscribe: () => (this.onVersion = null) };
|
||||
}
|
||||
|
||||
read(): Promise<FileReadResult> {
|
||||
const request = deferred<FileReadResult>();
|
||||
this.reads.push(request);
|
||||
return request.promise;
|
||||
}
|
||||
|
||||
emit(version: FileVersion): void {
|
||||
this.onVersion?.(version);
|
||||
}
|
||||
}
|
||||
|
||||
class DeferredSubscribeSession implements LiveFileSession {
|
||||
readonly reads: Deferred<FileReadResult>[] = [];
|
||||
readonly subscription = deferred<LiveFileSubscription>();
|
||||
private onVersion: ((version: FileVersion) => void) | null = null;
|
||||
|
||||
subscribe(
|
||||
_target: { cwd: string; path: string },
|
||||
onVersion: (version: FileVersion) => void,
|
||||
): Promise<LiveFileSubscription> {
|
||||
this.onVersion = onVersion;
|
||||
return this.subscription.promise;
|
||||
}
|
||||
|
||||
read(): Promise<FileReadResult> {
|
||||
const request = deferred<FileReadResult>();
|
||||
this.reads.push(request);
|
||||
return request.promise;
|
||||
}
|
||||
|
||||
emit(version: FileVersion): void {
|
||||
this.onVersion?.(version);
|
||||
}
|
||||
}
|
||||
|
||||
class FlakySubscriptionSession extends TestLiveFileSession {
|
||||
override async subscribe(
|
||||
target: { cwd: string; path: string },
|
||||
onVersion: (version: FileVersion) => void,
|
||||
): Promise<LiveFileSubscription> {
|
||||
if (this.subscribeAttempts === 0) {
|
||||
this.subscribeAttempts += 1;
|
||||
throw new Error("Connection closed.");
|
||||
}
|
||||
return super.subscribe(target, onVersion);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRead(
|
||||
session: { reads: Deferred<FileReadResult>[] },
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() => expect(session.reads).toHaveLength(count));
|
||||
}
|
||||
|
||||
describe("LiveFileModel", () => {
|
||||
it("reports initial and read-pending work before any terminal observation", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
|
||||
await waitForRead(session, 1);
|
||||
expect(model.getSnapshot()).toEqual({
|
||||
observation: null,
|
||||
read: { status: "pending", requested: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports read error as terminal and recovers on refresh", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({ session, target: { cwd: "/workspace", path: "file.ts" }, liveUpdates: false });
|
||||
await waitForRead(session, 1);
|
||||
session.reads[0].reject(new Error("read failed"));
|
||||
await vi.waitFor(() =>
|
||||
expect(model.getSnapshot().read).toEqual({ status: "error", error: "read failed" }),
|
||||
);
|
||||
model.refresh();
|
||||
await waitForRead(session, 2);
|
||||
session.reads[1].resolve(file("revision-2"));
|
||||
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
|
||||
});
|
||||
it("does not publish a missing candidate when the following read succeeds", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
session.reads[0].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
|
||||
const publishedStatuses: string[] = [];
|
||||
model.subscribe(() => {
|
||||
const observation = model.getObservation();
|
||||
if (observation) publishedStatuses.push(observation.status);
|
||||
});
|
||||
|
||||
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
await waitForRead(session, 2);
|
||||
session.reads[1].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" }));
|
||||
|
||||
expect(publishedStatuses).not.toContain("missing");
|
||||
});
|
||||
|
||||
it("publishes missing when the following read also fails", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
session.reads[0].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
|
||||
|
||||
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
await waitForRead(session, 2);
|
||||
session.reads[1].reject(new Error("File not found."));
|
||||
|
||||
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
|
||||
});
|
||||
|
||||
it("preserves a missing candidate for its queued confirming read", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
|
||||
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
session.reads[0].resolve(file());
|
||||
await waitForRead(session, 2);
|
||||
session.reads[1].reject(new Error("File not found."));
|
||||
|
||||
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing"));
|
||||
});
|
||||
|
||||
it("recovers only from a read begun after a missing observation", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
session.reads[0].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
|
||||
|
||||
model.refresh();
|
||||
await waitForRead(session, 2);
|
||||
session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" });
|
||||
session.reads[1].resolve(file("revision-2", "two"));
|
||||
|
||||
await waitForRead(session, 3);
|
||||
expect(model.getObservation()?.status).toBe("ready");
|
||||
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
|
||||
|
||||
session.reads[2].resolve(file("revision-2", "two"));
|
||||
await vi.waitFor(() => {
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
observation: {
|
||||
status: "ready",
|
||||
version: { revision: "revision-2" },
|
||||
file: { revision: "revision-2" },
|
||||
},
|
||||
read: { status: "idle" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an actionable error visible while refresh fails", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
session.reads[0].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" }));
|
||||
|
||||
session.emit({
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "Requested path is not a file",
|
||||
});
|
||||
await waitForRead(session, 2);
|
||||
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
|
||||
session.reads[1].reject(new Error("File unavailable."));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
observation: { status: "error", error: "Requested path is not a file" },
|
||||
read: { status: "error", error: "File unavailable." },
|
||||
});
|
||||
});
|
||||
|
||||
model.refresh();
|
||||
await waitForRead(session, 3);
|
||||
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
|
||||
});
|
||||
|
||||
it("keeps an update that arrives before the subscription response", async () => {
|
||||
const session = new DeferredSubscribeSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
|
||||
session.emit(ready("revision-2"));
|
||||
session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} });
|
||||
|
||||
await waitForRead(session, 1);
|
||||
expect(model.getObservation()).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a failed subscription when the user refreshes", async () => {
|
||||
const session = new FlakySubscriptionSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
expect(session.subscribeAttempts).toBe(1);
|
||||
|
||||
session.reads[0].resolve(file());
|
||||
await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready"));
|
||||
model.refresh();
|
||||
|
||||
await waitForRead(session, 2);
|
||||
expect(session.subscribeAttempts).toBe(2);
|
||||
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true });
|
||||
});
|
||||
|
||||
it("reads once without subscribing when live updates are disabled", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: false,
|
||||
});
|
||||
|
||||
await waitForRead(session, 1);
|
||||
expect(session.subscribeAttempts).toBe(0);
|
||||
expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false });
|
||||
});
|
||||
|
||||
it("ignores a read that completes after the model closes", async () => {
|
||||
const session = new TestLiveFileSession();
|
||||
const model = new LiveFileModel();
|
||||
model.open({
|
||||
session,
|
||||
target: { cwd: "/workspace", path: "file.ts" },
|
||||
liveUpdates: true,
|
||||
});
|
||||
await waitForRead(session, 1);
|
||||
|
||||
model.close();
|
||||
session.reads[0].resolve(file());
|
||||
await Promise.resolve();
|
||||
|
||||
expect(model.getSnapshot()).toEqual({
|
||||
observation: null,
|
||||
read: { status: "idle" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface LiveFileTarget {
|
||||
cwd: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface LiveFileSubscription {
|
||||
initial: FileVersion;
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
export interface LiveFileSession {
|
||||
subscribe(
|
||||
target: LiveFileTarget,
|
||||
onVersion: (version: FileVersion) => void,
|
||||
): Promise<LiveFileSubscription>;
|
||||
read(target: LiveFileTarget): Promise<FileReadResult>;
|
||||
}
|
||||
|
||||
export type LiveFileReadState =
|
||||
| { status: "idle" }
|
||||
| { status: "pending"; requested: boolean }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
export type LiveFileObservation =
|
||||
| {
|
||||
status: "ready";
|
||||
version: Extract<FileVersion, { status: "ready" }>;
|
||||
file: FileReadResult;
|
||||
}
|
||||
| Extract<FileVersion, { status: "missing" | "error" }>;
|
||||
|
||||
export interface LiveFileSnapshot {
|
||||
observation: LiveFileObservation | null;
|
||||
read: LiveFileReadState;
|
||||
}
|
||||
|
||||
const idleSnapshot: LiveFileSnapshot = {
|
||||
observation: null,
|
||||
read: { status: "idle" },
|
||||
};
|
||||
|
||||
export class LiveFileModel {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: LiveFileSnapshot = idleSnapshot;
|
||||
private session: LiveFileSession | null = null;
|
||||
private target: LiveFileTarget | null = null;
|
||||
private liveUpdates = false;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private subscriptionPending = false;
|
||||
private generation = 0;
|
||||
private issuedReadSequence = 0;
|
||||
private activeReadSequence: number | null = null;
|
||||
private activeReadRequested = false;
|
||||
private observedAfterReadSequence: number | null = null;
|
||||
private candidateVersion: FileVersion | null = null;
|
||||
private refreshQueued = false;
|
||||
private requestedRefreshQueued = false;
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): LiveFileSnapshot => this.snapshot;
|
||||
|
||||
getObservation = (): LiveFileObservation | null => this.snapshot.observation;
|
||||
|
||||
open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void {
|
||||
this.reset();
|
||||
this.session = input.session;
|
||||
this.target = input.target;
|
||||
this.liveUpdates = input.liveUpdates;
|
||||
const generation = this.generation;
|
||||
if (!input.liveUpdates) {
|
||||
this.scheduleRead(false);
|
||||
return;
|
||||
}
|
||||
void this.startSubscription(generation);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
refresh = (): void => {
|
||||
if (!this.session || !this.target) return;
|
||||
if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) {
|
||||
void this.startSubscription(this.generation);
|
||||
}
|
||||
this.scheduleRead(true);
|
||||
};
|
||||
|
||||
private scheduleRead(requested: boolean): void {
|
||||
if (this.activeReadSequence !== null) {
|
||||
this.refreshQueued = true;
|
||||
this.activeReadRequested ||= requested;
|
||||
this.requestedRefreshQueued ||= requested;
|
||||
if (requested && this.snapshot.read.status === "pending") {
|
||||
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.startRead(requested);
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.generation += 1;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.session = null;
|
||||
this.target = null;
|
||||
this.liveUpdates = false;
|
||||
this.subscriptionPending = false;
|
||||
this.issuedReadSequence = 0;
|
||||
this.activeReadSequence = null;
|
||||
this.activeReadRequested = false;
|
||||
this.observedAfterReadSequence = null;
|
||||
this.candidateVersion = null;
|
||||
this.refreshQueued = false;
|
||||
this.requestedRefreshQueued = false;
|
||||
this.setSnapshot(idleSnapshot);
|
||||
}
|
||||
|
||||
private async startSubscription(generation: number): Promise<void> {
|
||||
const session = this.session;
|
||||
const target = this.target;
|
||||
if (!session || !target) return;
|
||||
this.subscriptionPending = true;
|
||||
let receivedUpdate = false;
|
||||
try {
|
||||
const subscription = await session.subscribe(target, (version) => {
|
||||
if (generation !== this.generation) return;
|
||||
receivedUpdate = true;
|
||||
this.observeVersion(version);
|
||||
});
|
||||
if (generation !== this.generation) {
|
||||
subscription.unsubscribe();
|
||||
return;
|
||||
}
|
||||
this.subscriptionPending = false;
|
||||
this.unsubscribe = subscription.unsubscribe;
|
||||
if (!receivedUpdate) this.observeVersion(subscription.initial);
|
||||
} catch {
|
||||
if (generation !== this.generation) return;
|
||||
this.subscriptionPending = false;
|
||||
this.scheduleRead(false);
|
||||
}
|
||||
}
|
||||
|
||||
private observeVersion(version: FileVersion): void {
|
||||
this.observedAfterReadSequence = this.issuedReadSequence;
|
||||
this.candidateVersion = version;
|
||||
this.scheduleRead(false);
|
||||
}
|
||||
|
||||
private startRead(requested: boolean): void {
|
||||
const session = this.session;
|
||||
const target = this.target;
|
||||
if (!session || !target) return;
|
||||
const generation = this.generation;
|
||||
const sequence = ++this.issuedReadSequence;
|
||||
this.activeReadSequence = sequence;
|
||||
this.activeReadRequested = requested;
|
||||
this.refreshQueued = false;
|
||||
this.requestedRefreshQueued = false;
|
||||
this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } });
|
||||
void session.read(target).then(
|
||||
(file) => this.finishRead({ generation, sequence, target, file }),
|
||||
(error) => this.failRead({ generation, sequence, target, error }),
|
||||
);
|
||||
}
|
||||
|
||||
private finishRead(input: {
|
||||
generation: number;
|
||||
sequence: number;
|
||||
target: LiveFileTarget;
|
||||
file: FileReadResult;
|
||||
}): void {
|
||||
if (!this.isActiveRead(input.generation, input.sequence)) return;
|
||||
const requested = this.activeReadRequested;
|
||||
this.activeReadSequence = null;
|
||||
this.activeReadRequested = false;
|
||||
if (this.readPredatesLatestObservation(input.sequence)) {
|
||||
this.startRead(requested || this.takeQueuedRequested());
|
||||
return;
|
||||
}
|
||||
this.observedAfterReadSequence = null;
|
||||
this.candidateVersion = null;
|
||||
this.setSnapshot({
|
||||
observation: {
|
||||
status: "ready",
|
||||
file: input.file,
|
||||
version: readyVersion(input.target, input.file),
|
||||
},
|
||||
read: { status: "idle" },
|
||||
});
|
||||
this.startQueuedRead();
|
||||
}
|
||||
|
||||
private failRead(input: {
|
||||
generation: number;
|
||||
sequence: number;
|
||||
target: LiveFileTarget;
|
||||
error: unknown;
|
||||
}): void {
|
||||
if (!this.isActiveRead(input.generation, input.sequence)) return;
|
||||
const requested = this.activeReadRequested;
|
||||
this.activeReadSequence = null;
|
||||
this.activeReadRequested = false;
|
||||
if (this.readPredatesLatestObservation(input.sequence)) {
|
||||
this.startRead(requested || this.takeQueuedRequested());
|
||||
return;
|
||||
}
|
||||
const message = errorMessage(input.error);
|
||||
const candidate = this.candidateVersion;
|
||||
this.candidateVersion = null;
|
||||
const candidateDescribesFailure =
|
||||
candidate?.status === "missing" || candidate?.status === "error";
|
||||
const observation: LiveFileObservation = candidateDescribesFailure
|
||||
? candidate
|
||||
: { status: "error", cwd: input.target.cwd, path: input.target.path, error: message };
|
||||
this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } });
|
||||
this.startQueuedRead();
|
||||
}
|
||||
|
||||
private isActiveRead(generation: number, sequence: number): boolean {
|
||||
return generation === this.generation && sequence === this.activeReadSequence;
|
||||
}
|
||||
|
||||
private readPredatesLatestObservation(sequence: number): boolean {
|
||||
return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence;
|
||||
}
|
||||
|
||||
private startQueuedRead(): void {
|
||||
if (!this.refreshQueued) return;
|
||||
this.startRead(this.takeQueuedRequested());
|
||||
}
|
||||
|
||||
private takeQueuedRequested(): boolean {
|
||||
const requested = this.requestedRefreshQueued;
|
||||
this.refreshQueued = false;
|
||||
this.requestedRefreshQueued = false;
|
||||
return requested;
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: LiveFileSnapshot): void {
|
||||
if (snapshot === this.snapshot) return;
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function readyVersion(
|
||||
target: LiveFileTarget,
|
||||
file: FileReadResult,
|
||||
): Extract<FileVersion, { status: "ready" }> {
|
||||
return {
|
||||
status: "ready",
|
||||
cwd: target.cwd,
|
||||
path: target.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
+110
-584
@@ -1,194 +1,27 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { FilePanelPreview, resolveFilePreviewPanel, type FilePanel } from "@/file-panels";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFileHandle, useFileHandleSnapshot } from "@/file-panels/file";
|
||||
import { FilePanelLoading } from "@/file-panels/loading";
|
||||
import { FilePanelPicker, FilePanelView, useFilePanelChoice, type FilePanel } from "@/file-panels";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file/hook";
|
||||
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||
import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model";
|
||||
import { FilePanelBar } from "./bar";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||
import { createFileObservationSource } from "./editor/observation-source";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { FileSourceView } from "./source/view";
|
||||
import type { FileConflictAlertState } from "./conflict-alert";
|
||||
import type { LiveFileModel } from "./live-file/model";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
|
||||
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
|
||||
const sourceAppearance = (theme: Theme) => ({
|
||||
theme: {
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
},
|
||||
});
|
||||
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
|
||||
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
panel: FilePanel | null;
|
||||
preview: ExplorerFile | null;
|
||||
mode?: "preview" | "source";
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
preview,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
preview: ExplorerFile;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ThemedFileSourceView
|
||||
content={preview.content ?? ""}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
size={preview.size}
|
||||
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooLargeSource({ size }: { size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
panel,
|
||||
preview,
|
||||
mode,
|
||||
isLoading,
|
||||
isMobile: _isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-preview-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-preview-unsupported">
|
||||
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source");
|
||||
if (panel && !showSource) {
|
||||
return (
|
||||
<FilePanelPreview
|
||||
panel={panel}
|
||||
content={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (preview.kind === "text") {
|
||||
return (
|
||||
<ReadonlySource
|
||||
preview={preview}
|
||||
filename={filePath}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { FilePanelBar } from "./bar";
|
||||
|
||||
/**
|
||||
* The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows
|
||||
* its own loading; the tab only knows the file exists, its kind, and whether the panel has
|
||||
* unsaved changes.
|
||||
*/
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
@@ -201,417 +34,120 @@ export function FilePane({
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useAppSettings();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
const connected = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
|
||||
const root = workspaceRoot.trim();
|
||||
const path = location.path.trim();
|
||||
const target = useMemo(
|
||||
() => (path ? resolveFilePreviewReadTarget({ path, workspaceRoot: root }) : null),
|
||||
[path, root],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
? resolveFilePreviewReadTarget({
|
||||
path: normalizedFilePath,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
})
|
||||
: null,
|
||||
[normalizedFilePath, normalizedWorkspaceRoot],
|
||||
const absolutePath = useMemo(
|
||||
() => buildAbsoluteExplorerPath({ workspaceRoot: root, entryPath: path }),
|
||||
[path, root],
|
||||
);
|
||||
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches; active app visibility covers backgrounding and returning
|
||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||
// A hidden tab or a backgrounded app stops observing; observing again reports what changed.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppActivelyVisible();
|
||||
const enabled = isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
const handle = useFileHandle({
|
||||
serverId,
|
||||
target,
|
||||
absolutePath,
|
||||
observing: isActive && isAppVisible,
|
||||
});
|
||||
const liveFile = useLiveFile({
|
||||
client,
|
||||
cwd: readTarget?.cwd ?? null,
|
||||
path: readTarget?.path ?? null,
|
||||
enabled,
|
||||
liveUpdates: supportsEditing,
|
||||
});
|
||||
|
||||
const targetKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
const previewLifecycle = useFilePreview({
|
||||
const snapshot = useFileHandleSnapshot(handle);
|
||||
const targetKey = target ? `${target.cwd}:${target.path}` : null;
|
||||
const { panel, choice } = useFilePanelChoice({
|
||||
serverId,
|
||||
targetKey,
|
||||
liveFileSnapshot: liveFile.snapshot,
|
||||
path,
|
||||
fileKind: snapshot.status === "ready" ? snapshot.kind : null,
|
||||
presentable: isActive,
|
||||
lineNavigation: location.lineStart ? navigationRevision : null,
|
||||
});
|
||||
|
||||
useEffect(() => setPreviewMode("preview"), [targetKey]);
|
||||
|
||||
const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle);
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment);
|
||||
const panel = resolveFilePreviewPanel({
|
||||
preview,
|
||||
path: location.path,
|
||||
associations: settings.filePanelAssociations,
|
||||
});
|
||||
const isRenderable = preview?.kind === "text" && panel !== null;
|
||||
const editable = isEditableTextFile({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const canTogglePreviewMode = isRenderable && !location.lineStart;
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = previewLifecycle.status === "error" ? previewLifecycle.message : null;
|
||||
const isLoading =
|
||||
previewLifecycle.status === "initial" ||
|
||||
previewLifecycle.status === "read_pending" ||
|
||||
previewLifecycle.status === "preparing";
|
||||
|
||||
return (
|
||||
<FilePanePresentation
|
||||
panel={panel}
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
liveFile={liveFile.model}
|
||||
onRetryRead={liveFile.refresh}
|
||||
retryingRead={liveFile.isRetrying}
|
||||
retryLabel={t("common.actions.retry")}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
previewMode={canTogglePreviewMode ? previewMode : undefined}
|
||||
onPreviewModeChange={canTogglePreviewMode ? setPreviewMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
const [modified, setModified] = useState(false);
|
||||
usePublishPanelInstanceAttributes({ modified, suspendPendingSave: handle?.holdWrites });
|
||||
const panelLocation = useMemo(
|
||||
() => ({
|
||||
lineStart: location.lineStart,
|
||||
lineEnd: location.lineEnd,
|
||||
revision: navigationRevision,
|
||||
}),
|
||||
[location.lineEnd, location.lineStart, navigationRevision],
|
||||
);
|
||||
}
|
||||
|
||||
function isEditableTextFile(input: {
|
||||
preview: ExplorerFile | null;
|
||||
supportsEditing: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
isWeb &&
|
||||
input.supportsEditing &&
|
||||
input.preview?.kind === "text" &&
|
||||
input.preview.size <= 1024 * 1024,
|
||||
const panelTarget = useMemo(
|
||||
() => ({ serverId, workspaceRoot: root, path }),
|
||||
[path, root, serverId],
|
||||
);
|
||||
}
|
||||
|
||||
function FilePanePresentation({
|
||||
panel,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
liveFile,
|
||||
onRetryRead,
|
||||
retryingRead,
|
||||
retryLabel,
|
||||
filename,
|
||||
previewMode,
|
||||
onPreviewModeChange,
|
||||
lineCount,
|
||||
editable,
|
||||
disconnectedMessage,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
panel: FilePanel | null;
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
liveFile: LiveFileModel;
|
||||
onRetryRead: () => void;
|
||||
retryingRead: boolean;
|
||||
retryLabel: string;
|
||||
filename: string;
|
||||
previewMode?: "preview" | "source";
|
||||
onPreviewModeChange?: (mode: "preview" | "source") => void;
|
||||
lineCount?: number;
|
||||
editable: boolean;
|
||||
disconnectedMessage: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
}) {
|
||||
if (!client && readTarget) {
|
||||
let body;
|
||||
if (!connected && target) {
|
||||
body = <Text style={styles.errorText}>{t("workspace.terminal.hostDisconnected")}</Text>;
|
||||
} else if (!handle || snapshot.status === "loading") {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{disconnectedMessage}</Text>
|
||||
</View>
|
||||
<FilePanelLoading />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (editable && client && readTarget && preview?.kind === "text") {
|
||||
return (
|
||||
<EditableFilePane
|
||||
panel={panel}
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
preview={preview as TextExplorerFile}
|
||||
liveFile={liveFile}
|
||||
onRetryRead={onRetryRead}
|
||||
retryingRead={retryingRead}
|
||||
filename={filename}
|
||||
mode={previewMode}
|
||||
onModeChange={onPreviewModeChange}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
/>
|
||||
} else if (snapshot.status === "missing" || snapshot.status === "error") {
|
||||
body = (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
{snapshot.status === "error" ? snapshot.error : t("panels.file.notFound")}
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={handle.retry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
if (errorMessage === "File is too large to display") {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<TooLargeSource />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
} else if (!panel) {
|
||||
body = (
|
||||
<>
|
||||
<Text style={styles.mutedText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.mutedText}>{formatFileSize(snapshot.size)}</Text>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Button variant="outline" size="sm" onPress={onRetryRead} loading={retryingRead}>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{preview ? (
|
||||
<FilePanelBar
|
||||
size={preview.size}
|
||||
lineCount={lineCount}
|
||||
mode={previewMode}
|
||||
onModeChange={onPreviewModeChange}
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewBody
|
||||
panel={panel}
|
||||
preview={preview}
|
||||
mode={previewMode}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
panel,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
preview,
|
||||
liveFile,
|
||||
onRetryRead,
|
||||
retryingRead,
|
||||
filename,
|
||||
mode,
|
||||
onModeChange,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
panel: FilePanel | null;
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
preview: TextExplorerFile;
|
||||
liveFile: LiveFileModel;
|
||||
onRetryRead: () => void;
|
||||
retryingRead: boolean;
|
||||
filename: string;
|
||||
mode?: "preview" | "source";
|
||||
onModeChange?: (mode: "preview" | "source") => void;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const session = useMemo(
|
||||
() => ({
|
||||
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
|
||||
return client.writeFile({ cwd, path, ...input });
|
||||
},
|
||||
}),
|
||||
[client, cwd, path],
|
||||
);
|
||||
const [model] = useState(() => {
|
||||
return new FileEditorModel({
|
||||
file: {
|
||||
content: preview.content ?? "",
|
||||
hasBom: preview.hasBom,
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
path,
|
||||
size: preview.size,
|
||||
modifiedAt: preview.modifiedAt,
|
||||
revision: preview.revision,
|
||||
},
|
||||
},
|
||||
session,
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
const source = createFileObservationSource(liveFile);
|
||||
model.connectFileObservations(source);
|
||||
return () => model.disconnectFileObservations();
|
||||
}, [liveFile, model]);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!snapshot.modified) {
|
||||
void model.reload();
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("panels.file.editor.reloadTitle"),
|
||||
message: t("panels.file.editor.reloadMessage"),
|
||||
confirmLabel: t("panels.file.editor.reload"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) void model.reload();
|
||||
})();
|
||||
}, [model, snapshot.modified, t]);
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const conflict = fileConflictAlertState({
|
||||
callout: getFileConflictCallout(snapshot),
|
||||
onOverwrite: handleOverwrite,
|
||||
onReload: handleReload,
|
||||
onRetry: onRetryRead,
|
||||
retrying: retryingRead,
|
||||
});
|
||||
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
|
||||
const renderedPreview = useMemo<ExplorerFile>(
|
||||
() => ({
|
||||
...preview,
|
||||
content: snapshot.content,
|
||||
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
|
||||
modifiedAt:
|
||||
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
|
||||
}),
|
||||
[preview, snapshot.content, snapshot.version],
|
||||
);
|
||||
const showSource = mode !== "preview";
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<FilePanelBar
|
||||
size={
|
||||
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
|
||||
}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
editorStatus={snapshot.status}
|
||||
cursor={showSource ? cursor : undefined}
|
||||
vimMode={showSource ? vimMode : null}
|
||||
conflict={conflict}
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
/>
|
||||
{showSource ? (
|
||||
<ThemedFileEditorView
|
||||
model={model}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={handleVimModeChange}
|
||||
/>
|
||||
) : (
|
||||
<FilePreviewBody
|
||||
<FilePanelPicker choice={choice} />
|
||||
<FilePanelBar choice={choice} modified={modified} />
|
||||
<MountedFilePanel
|
||||
key={`${panel.id}:${targetKey}`}
|
||||
panel={panel}
|
||||
preview={renderedPreview}
|
||||
mode={mode}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
file={handle.file}
|
||||
location={panelLocation}
|
||||
target={panelTarget}
|
||||
onModifiedChange={setModified}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>{body}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function fileConflictAlertState(input: {
|
||||
callout: FileConflictCallout | null;
|
||||
onOverwrite(): void;
|
||||
onReload(): void;
|
||||
onRetry(): void;
|
||||
retrying: boolean;
|
||||
}): FileConflictAlertState | undefined {
|
||||
if (!input.callout) return undefined;
|
||||
if (input.callout.kind === "deleted") return { kind: "deleted" };
|
||||
if (input.callout.kind === "checkFailed") {
|
||||
return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry };
|
||||
}
|
||||
return {
|
||||
kind: "changed",
|
||||
canOverwrite: input.callout.canOverwrite,
|
||||
onReload: input.onReload,
|
||||
onOverwrite: input.onOverwrite,
|
||||
};
|
||||
/** One panel mount. Its tab reports unsaved changes until the panel unmounts. */
|
||||
function MountedFilePanel({
|
||||
panel,
|
||||
file,
|
||||
location,
|
||||
target,
|
||||
onModifiedChange,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
file: PluginFile;
|
||||
location: { lineStart?: number; lineEnd?: number; revision: number };
|
||||
target: { serverId: string; workspaceRoot: string; path: string };
|
||||
onModifiedChange: (modified: boolean) => void;
|
||||
}) {
|
||||
const [tab] = useState<PluginFileTab>(() => ({
|
||||
setModified: (modified) => onModifiedChange(modified),
|
||||
}));
|
||||
useEffect(() => () => onModifiedChange(false), [onModifiedChange]);
|
||||
return <FilePanelView panel={panel} file={file} tab={tab} location={location} target={target} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -627,24 +163,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
binaryMetaText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import type { LiveFileSnapshot } from "../live-file/model";
|
||||
import {
|
||||
createFilePanePreview,
|
||||
FilePreviewLifecycleModel,
|
||||
type FilePreviewLifecycleSnapshot,
|
||||
} from "./model";
|
||||
|
||||
/** Bridges the raw-file owner to the preview preparation owner. */
|
||||
export function useFilePreview(input: {
|
||||
targetKey: string | null;
|
||||
liveFileSnapshot: LiveFileSnapshot;
|
||||
}): FilePreviewLifecycleSnapshot {
|
||||
const [model] = useState(() => new FilePreviewLifecycleModel(createFilePanePreview));
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const { targetKey, liveFileSnapshot } = input;
|
||||
|
||||
useEffect(() => {
|
||||
model.setSource({ targetKey, liveFileSnapshot });
|
||||
}, [liveFileSnapshot, model, targetKey]);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { LiveFileSnapshot } from "../live-file/model";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FilePreviewLifecycleModel,
|
||||
type FilePanePreview,
|
||||
type FilePreviewLifecycleSnapshot,
|
||||
} from "./model";
|
||||
|
||||
function previewFile(content: string) {
|
||||
return {
|
||||
kind: "text" as const,
|
||||
path: "file.ts",
|
||||
size: 7,
|
||||
encoding: "utf-8" as const,
|
||||
hasBom: false,
|
||||
modifiedAt: "2026-08-20T00:00:00.000Z",
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function file(revision = "revision-1", path = "file.ts"): FileReadResult {
|
||||
return {
|
||||
bytes: new TextEncoder().encode("preview"),
|
||||
mime: "text/plain",
|
||||
size: 7,
|
||||
path,
|
||||
kind: "text",
|
||||
modifiedAt: "2026-08-20T00:00:00.000Z",
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
function pending(): LiveFileSnapshot {
|
||||
return { observation: null, read: { status: "pending", requested: false } };
|
||||
}
|
||||
|
||||
function completed(rawFile: FileReadResult): LiveFileSnapshot {
|
||||
return {
|
||||
observation: {
|
||||
status: "ready",
|
||||
file: rawFile,
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd: "/workspace",
|
||||
path: rawFile.path,
|
||||
size: rawFile.size,
|
||||
modifiedAt: rawFile.modifiedAt,
|
||||
revision: rawFile.revision,
|
||||
},
|
||||
},
|
||||
read: { status: "idle" },
|
||||
};
|
||||
}
|
||||
|
||||
function source(
|
||||
targetKey: string,
|
||||
snapshot: LiveFileSnapshot,
|
||||
): {
|
||||
targetKey: string;
|
||||
liveFileSnapshot: LiveFileSnapshot;
|
||||
} {
|
||||
return { targetKey, liveFileSnapshot: snapshot };
|
||||
}
|
||||
|
||||
describe("FilePreviewLifecycleModel", () => {
|
||||
it("represents pending raw reads, preparation, ready previews, unsupported files, and conversion failures", async () => {
|
||||
const first = deferred<FilePanePreview | null>();
|
||||
const second = deferred<FilePanePreview | null>();
|
||||
const third = deferred<FilePanePreview | null>();
|
||||
const preparations = [first, second, third];
|
||||
const model = new FilePreviewLifecycleModel(() => preparations.shift()!.promise);
|
||||
const preview: FilePanePreview = {
|
||||
file: previewFile("preview"),
|
||||
imageAttachment: null,
|
||||
};
|
||||
|
||||
model.setSource(source("/workspace:file.ts", pending()));
|
||||
expect(model.getSnapshot()).toEqual({ status: "read_pending" });
|
||||
|
||||
model.setSource(source("/workspace:file.ts", completed(file("one"))));
|
||||
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||
first.resolve(preview);
|
||||
await expectSnapshot(model, { status: "ready", preview });
|
||||
|
||||
model.setSource(source("/workspace:file.ts", pending()));
|
||||
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
|
||||
|
||||
model.setSource(
|
||||
source("/workspace:file.ts", {
|
||||
observation: {
|
||||
status: "error",
|
||||
cwd: "/workspace",
|
||||
path: "file.ts",
|
||||
error: "Requested path is not a file",
|
||||
},
|
||||
read: { status: "pending", requested: true },
|
||||
}),
|
||||
);
|
||||
expect(model.getSnapshot()).toEqual({ status: "read_pending", preview });
|
||||
|
||||
model.setSource(source("/workspace:file.ts", completed(file("two"))));
|
||||
expect(model.getSnapshot()).toEqual({ status: "preparing", preview });
|
||||
second.resolve(null);
|
||||
await expectSnapshot(model, { status: "unsupported" });
|
||||
|
||||
model.setSource(source("/workspace:file.ts", completed(file("three"))));
|
||||
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||
third.reject(new Error("attachment failed"));
|
||||
await expectSnapshot(model, { status: "error", message: "attachment failed" });
|
||||
});
|
||||
|
||||
it("does not publish a stale conversion after retargeting", async () => {
|
||||
const first = deferred<FilePanePreview | null>();
|
||||
const second = deferred<FilePanePreview | null>();
|
||||
const model = new FilePreviewLifecycleModel((rawFile) =>
|
||||
rawFile.revision === "one" ? first.promise : second.promise,
|
||||
);
|
||||
const nextPreview: FilePanePreview = {
|
||||
file: previewFile("two"),
|
||||
imageAttachment: null,
|
||||
};
|
||||
|
||||
model.setSource(source("/workspace:file.ts", completed(file("one"))));
|
||||
model.setSource(source("/workspace:second.ts", completed(file("two", "second.ts"))));
|
||||
first.resolve({ file: previewFile("one"), imageAttachment: null });
|
||||
await Promise.resolve();
|
||||
expect(model.getSnapshot()).toEqual({ status: "preparing" });
|
||||
|
||||
second.resolve(nextPreview);
|
||||
await expectSnapshot(model, { status: "ready", preview: nextPreview });
|
||||
});
|
||||
});
|
||||
|
||||
async function expectSnapshot(
|
||||
model: FilePreviewLifecycleModel,
|
||||
expected: FilePreviewLifecycleSnapshot,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
expect(model.getSnapshot()).toEqual(expected);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
import type { LiveFileSnapshot } from "../live-file/model";
|
||||
|
||||
export interface FilePanePreview {
|
||||
file: ExplorerFile;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}
|
||||
|
||||
export type FilePreviewLifecycleSnapshot =
|
||||
| { status: "initial" }
|
||||
| { status: "read_pending"; preview?: FilePanePreview }
|
||||
| { status: "preparing"; preview?: FilePanePreview }
|
||||
| { status: "ready"; preview: FilePanePreview }
|
||||
| { status: "error"; message: string; preview?: FilePanePreview }
|
||||
| { status: "unsupported" };
|
||||
|
||||
const initialSnapshot: FilePreviewLifecycleSnapshot = { status: "initial" };
|
||||
|
||||
/** Converts a completed raw read into the preview resources consumed by FilePane. */
|
||||
export async function createFilePanePreview(file: FileReadResult): Promise<FilePanePreview | null> {
|
||||
const explorerFile = explorerFileFromReadResult(file);
|
||||
if (file.kind !== "image") {
|
||||
return { file: explorerFile, imageAttachment: null };
|
||||
}
|
||||
|
||||
const imageAttachment = await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: file.mime,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path),
|
||||
});
|
||||
|
||||
return { file: explorerFile, imageAttachment };
|
||||
}
|
||||
|
||||
/** Owns conversion after LiveFileModel has produced a raw file snapshot. */
|
||||
export class FilePreviewLifecycleModel {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FilePreviewLifecycleSnapshot = initialSnapshot;
|
||||
private generation = 0;
|
||||
private preparedSourceKey: string | null = null;
|
||||
private targetKey: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prepare: (file: FileReadResult) => Promise<FilePanePreview | null>,
|
||||
) {}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FilePreviewLifecycleSnapshot => this.snapshot;
|
||||
|
||||
setSource(input: { targetKey: string | null; liveFileSnapshot: LiveFileSnapshot }): void {
|
||||
const targetChanged = input.targetKey !== this.targetKey;
|
||||
const retainedPreview = targetChanged ? undefined : filePreviewFromLifecycle(this.snapshot);
|
||||
if (targetChanged) {
|
||||
this.targetKey = input.targetKey;
|
||||
this.preparedSourceKey = null;
|
||||
this.generation += 1;
|
||||
}
|
||||
|
||||
const source = getReadySource(input);
|
||||
if (source) {
|
||||
if (source.key === this.preparedSourceKey) {
|
||||
return;
|
||||
}
|
||||
this.preparedSourceKey = source.key;
|
||||
const generation = ++this.generation;
|
||||
this.setSnapshot({
|
||||
status: "preparing",
|
||||
...(retainedPreview ? { preview: retainedPreview } : {}),
|
||||
});
|
||||
void this.prepareSource({ file: source.file, generation, retainedPreview });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSnapshot = getNonReadySnapshot(input.liveFileSnapshot);
|
||||
if (nextSnapshot.status === "initial" && retainedPreview) {
|
||||
return;
|
||||
}
|
||||
const retainedSnapshot = retainedPreview
|
||||
? { ...nextSnapshot, preview: retainedPreview }
|
||||
: nextSnapshot;
|
||||
if (sameNonReadyState(this.snapshot, retainedSnapshot)) {
|
||||
return;
|
||||
}
|
||||
this.preparedSourceKey = null;
|
||||
this.generation += 1;
|
||||
this.setSnapshot(retainedSnapshot);
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: FilePreviewLifecycleSnapshot): void {
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
private async prepareSource(input: {
|
||||
file: FileReadResult;
|
||||
generation: number;
|
||||
retainedPreview?: FilePanePreview;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const preview = await this.prepare(input.file);
|
||||
if (input.generation !== this.generation) return;
|
||||
this.setSnapshot(preview ? { status: "ready", preview } : { status: "unsupported" });
|
||||
} catch (error) {
|
||||
if (input.generation !== this.generation) return;
|
||||
this.setSnapshot({
|
||||
status: "error",
|
||||
message: errorMessage(error),
|
||||
...(input.retainedPreview ? { preview: input.retainedPreview } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filePreviewFromLifecycle(
|
||||
snapshot: FilePreviewLifecycleSnapshot,
|
||||
): FilePanePreview | undefined {
|
||||
return "preview" in snapshot ? snapshot.preview : undefined;
|
||||
}
|
||||
|
||||
export function resolveFilePreviewLifecycle(snapshot: FilePreviewLifecycleSnapshot): {
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
} {
|
||||
const preview = filePreviewFromLifecycle(snapshot);
|
||||
return preview ?? { file: null, imageAttachment: null };
|
||||
}
|
||||
|
||||
function getReadySource(input: {
|
||||
targetKey: string | null;
|
||||
liveFileSnapshot: LiveFileSnapshot;
|
||||
}): { key: string; file: FileReadResult } | null {
|
||||
const observation = input.liveFileSnapshot.observation;
|
||||
if (!input.targetKey || observation?.status !== "ready") {
|
||||
return null;
|
||||
}
|
||||
const { version } = observation;
|
||||
if (input.targetKey !== `${version.cwd}:${version.path}`) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: `${input.targetKey}:${version.revision ?? version.modifiedAt}:${version.size}`,
|
||||
file: observation.file,
|
||||
};
|
||||
}
|
||||
|
||||
function getNonReadySnapshot(snapshot: LiveFileSnapshot): FilePreviewLifecycleSnapshot {
|
||||
if (snapshot.read.status === "error") {
|
||||
return { status: "error", message: snapshot.read.error };
|
||||
}
|
||||
if (snapshot.read.status === "pending") {
|
||||
return { status: "read_pending" };
|
||||
}
|
||||
if (snapshot.observation?.status === "error") {
|
||||
return { status: "error", message: snapshot.observation.error };
|
||||
}
|
||||
if (snapshot.observation?.status === "missing") {
|
||||
return { status: "error", message: "File not found" };
|
||||
}
|
||||
return initialSnapshot;
|
||||
}
|
||||
|
||||
function sameNonReadyState(
|
||||
current: FilePreviewLifecycleSnapshot,
|
||||
next: FilePreviewLifecycleSnapshot,
|
||||
): boolean {
|
||||
if (current.status !== next.status) {
|
||||
return false;
|
||||
}
|
||||
return current.status !== "error" || next.status !== "error" || current.message === next.message;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Failed to prepare preview";
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
theme: EditorVisualTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
interface SourceLine {
|
||||
number: number;
|
||||
tokens: HighlightToken[];
|
||||
}
|
||||
|
||||
export function FileSourceView({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
size,
|
||||
tooLargeMessage,
|
||||
}: FileSourceViewProps) {
|
||||
const presentation = selectSourcePresentation({ size, platform: "native" });
|
||||
if (presentation === "unsupported") {
|
||||
return (
|
||||
<View style={styles.unsupported} testID="file-source-too-large">
|
||||
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VirtualizedSource
|
||||
content={content}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
presentation={presentation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualizedSource({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
presentation,
|
||||
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||
presentation: "highlighted" | "plain";
|
||||
}) {
|
||||
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||
const lines = useMemo(() => {
|
||||
if (presentation === "highlighted")
|
||||
return highlightCode(content, filename).map((tokens, index) => ({
|
||||
number: index + 1,
|
||||
tokens,
|
||||
}));
|
||||
return content
|
||||
.split("\n")
|
||||
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||
}, [content, filename, presentation]);
|
||||
useEffect(() => {
|
||||
if (!location.lineStart) return;
|
||||
listRef.current?.scrollToIndex({
|
||||
index: Math.min(location.lineStart - 1, lines.length - 1),
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lines.length, location.lineStart, navigationRevision]);
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLineView({ line }: { line: SourceLine }) {
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{line.number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{line.tokens.map((token) => (
|
||||
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLineKey(line: SourceLine): string {
|
||||
return String(line.number);
|
||||
}
|
||||
|
||||
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
unsupported: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
|
||||
gutter: {
|
||||
width: 56,
|
||||
paddingRight: theme.spacing[3],
|
||||
textAlign: "right",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
export interface FilePanelAssociations {
|
||||
/** Selector key (`ext:md`, `name:Dockerfile`) → panel id, device-wide. */
|
||||
associations: Readonly<Record<string, string>>;
|
||||
/** Writes the association for a selector key, or forgets it with null. */
|
||||
remember(selectorKey: string, panelId: string | null): void;
|
||||
}
|
||||
|
||||
/** The one reader and writer of the device's file panel associations. */
|
||||
export function useFilePanelAssociations(): FilePanelAssociations {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const remember = useCallback(
|
||||
(selectorKey: string, panelId: string | null) => {
|
||||
void updateSettings((current) => ({
|
||||
filePanelAssociations: withAssociation(current.filePanelAssociations, selectorKey, panelId),
|
||||
}));
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
return { associations: settings.filePanelAssociations, remember };
|
||||
}
|
||||
|
||||
function withAssociation(
|
||||
associations: Readonly<Record<string, string>>,
|
||||
selectorKey: string,
|
||||
panelId: string | null,
|
||||
): Record<string, string> {
|
||||
const next = { ...associations };
|
||||
if (panelId === null) delete next[selectorKey];
|
||||
else next[selectorKey] = panelId;
|
||||
return next;
|
||||
}
|
||||
@@ -1,92 +1,94 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useFileText, useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileHtmlPreview } from "./html-preview";
|
||||
import { FilePanelLoading } from "./loading";
|
||||
import { FileMarkdownPreview } from "./markdown-preview";
|
||||
import type { builtinFilePanels } from "./catalog";
|
||||
import type { BuiltinFilePanelProps } from "./panels";
|
||||
import { SourcePanel } from "./source/panel";
|
||||
import type { RenderingFilePanel, SourceFilePanel } from "./catalog";
|
||||
|
||||
interface FilePanelPreviewProps {
|
||||
content: string;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
function MarkdownPanel(props: FilePanelPreviewProps) {
|
||||
/** Shows Loading until the rendered Markdown has laid out once; later re-reads keep the content. */
|
||||
function MarkdownPanel({ file, target }: BuiltinFilePanelProps) {
|
||||
const text = useFileText(file);
|
||||
const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null);
|
||||
const [rendered, setRendered] = useState(false);
|
||||
const handleLayout = useCallback(() => setRendered(true), []);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") return <FilePanelError message={text.error.message} />;
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<ScrollView style={styles.previewContent} showsVerticalScrollIndicator>
|
||||
<FileMarkdownPreview
|
||||
source={props.content}
|
||||
filePath={props.filePath}
|
||||
workspaceRoot={props.workspaceRoot}
|
||||
client={props.client}
|
||||
serverId={props.serverId}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.container} showsVerticalScrollIndicator>
|
||||
<View onLayout={handleLayout}>
|
||||
<FileMarkdownPreview
|
||||
source={text.text}
|
||||
filePath={target.path}
|
||||
workspaceRoot={target.workspaceRoot}
|
||||
client={client}
|
||||
serverId={target.serverId}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{rendered ? null : <FilePanelLoading />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlPanel({ content }: FilePanelPreviewProps) {
|
||||
function HtmlPanel({ file }: BuiltinFilePanelProps) {
|
||||
const text = useFileText(file);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") return <FilePanelError message={text.error.message} />;
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<FileHtmlPreview html={content} testID="file-html-preview" />
|
||||
<View style={styles.container}>
|
||||
<FileHtmlPreview html={text.text} testID="file-html-preview" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
function ImagePanel({ file }: BuiltinFilePanelProps) {
|
||||
const url = useFileUrl(file);
|
||||
if (url.status === "loading") return <FilePanelLoading />;
|
||||
if (url.status === "error") return <FilePanelError message={url.error.message} />;
|
||||
return <ZoomableImage uri={url.url} testID="image-file-preview" />;
|
||||
}
|
||||
|
||||
function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <ZoomableImage uri={imagePreviewUri} testID="image-file-preview" />;
|
||||
function FilePanelError({ message }: { message: string }) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const components = {
|
||||
"builtin:markdown": MarkdownPanel,
|
||||
"builtin:html": HtmlPanel,
|
||||
"builtin:image": ImagePanel,
|
||||
"builtin:source": SourcePanel,
|
||||
};
|
||||
|
||||
export function FilePanelPreview({
|
||||
export function BuiltinFilePanelView({
|
||||
panel,
|
||||
...props
|
||||
}: FilePanelPreviewProps & {
|
||||
panel: (typeof builtinFilePanels)[number];
|
||||
}) {
|
||||
}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) {
|
||||
const Component = components[panel.id];
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
previewScrollContainer: { flex: 1, minHeight: 0 },
|
||||
previewContent: { flex: 1, minHeight: 0 },
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,86 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
|
||||
import { resolveFilePanel } from "./resolve";
|
||||
import { filePanelCatalog } from "./catalog";
|
||||
import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
|
||||
|
||||
function filePreviewRenderKind(path: string) {
|
||||
const { panel } = resolveFilePanel({
|
||||
file: { path, kind: "text", size: 100, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
associations: {},
|
||||
});
|
||||
return panel ? panel.id.slice("builtin:".length) : null;
|
||||
const builtins = filePanelCatalog([]);
|
||||
|
||||
const richMarkdown: FilePanelCandidate = {
|
||||
id: "plugin:notes/rich",
|
||||
builtin: false,
|
||||
files: { extensions: ["md"] },
|
||||
};
|
||||
|
||||
function textFile(path: string): FilePanelFile {
|
||||
return { path, kind: "text" };
|
||||
}
|
||||
|
||||
describe("isRenderedMarkdownFile", () => {
|
||||
it("detects .md files", () => {
|
||||
expect(isRenderedMarkdownFile("README.md")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
|
||||
});
|
||||
function candidateIds(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
|
||||
return resolveFilePanel({ file, candidates, associations: {} }).candidates.map(
|
||||
(candidate) => candidate.id,
|
||||
);
|
||||
}
|
||||
|
||||
it("detects .markdown files", () => {
|
||||
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat .mdx files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat other text files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("plan.html")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePreviewRenderKind", () => {
|
||||
it("maps each renderable extension to its kind", () => {
|
||||
expect(filePreviewRenderKind("README.md")).toBe("markdown");
|
||||
expect(filePreviewRenderKind("notes.markdown")).toBe("markdown");
|
||||
expect(filePreviewRenderKind("plan.html")).toBe("html");
|
||||
expect(filePreviewRenderKind("docs/PLAN.HTML")).toBe("html");
|
||||
expect(filePreviewRenderKind("plan.htm")).toBe("html");
|
||||
});
|
||||
|
||||
it("returns null for files without a rendered preview", () => {
|
||||
expect(filePreviewRenderKind("src/index.ts")).toBe(null);
|
||||
expect(filePreviewRenderKind("page.mdx")).toBe(null);
|
||||
expect(filePreviewRenderKind("index.html.erb")).toBe(null);
|
||||
});
|
||||
});
|
||||
function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] = builtins) {
|
||||
return resolveFilePanel({ file, candidates, associations: {} }).panel?.id ?? null;
|
||||
}
|
||||
|
||||
describe("built-in file panels", () => {
|
||||
it("opens each rendered extension with its panel and Source after it", () => {
|
||||
expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]);
|
||||
expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown");
|
||||
expect(panelId(textFile("plan.html"))).toBe("builtin:html");
|
||||
expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html");
|
||||
expect(panelId(textFile("plan.htm"))).toBe("builtin:html");
|
||||
});
|
||||
|
||||
it("opens every other text file with Source, whatever its name", () => {
|
||||
for (const path of ["src/index.ts", "page.mdx", "index.html.erb", "Dockerfile", "LICENSE"]) {
|
||||
expect(candidateIds(textFile(path))).toEqual(["builtin:source"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts plugin panels after the rendering built-ins and before Source", () => {
|
||||
expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([
|
||||
"builtin:markdown",
|
||||
richMarkdown.id,
|
||||
"builtin:source",
|
||||
]);
|
||||
const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]);
|
||||
expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id);
|
||||
});
|
||||
|
||||
it("has no Source for files that are not text", () => {
|
||||
expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["png", "jpg", "jpeg", "gif", "webp", "svg"])(
|
||||
"registers the image extension %s",
|
||||
(extension) => {
|
||||
const resolution = resolveFilePanel({
|
||||
file: {
|
||||
path: `/capture.${extension.toUpperCase()}`,
|
||||
kind: "image",
|
||||
size: 512,
|
||||
hasBytes: true,
|
||||
},
|
||||
candidates: builtinFilePanels,
|
||||
file: { path: `/capture.${extension.toUpperCase()}`, kind: "image" },
|
||||
candidates: builtins,
|
||||
associations: { [`ext:${extension}`]: "builtin:markdown" },
|
||||
});
|
||||
expect(resolution.panel?.id).toBe("builtin:image");
|
||||
expect(
|
||||
resolveFilePanel({
|
||||
file: { path: "capture.png", kind: "binary", size: 512, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
file: { path: "capture.png", kind: "binary" },
|
||||
candidates: builtins,
|
||||
associations: {},
|
||||
}).panel,
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not impose a new preview size limit on built-in text panels", () => {
|
||||
expect(
|
||||
resolveFilePanel({
|
||||
file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false },
|
||||
candidates: builtinFilePanels,
|
||||
associations: {},
|
||||
}).panel?.id,
|
||||
).toBe("builtin:markdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import { filePanelExtension, type FilePanelCandidate } from "./resolve";
|
||||
import type { FilePanelCandidate } from "./resolve";
|
||||
|
||||
const markdownPanel = {
|
||||
id: "builtin:markdown",
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
} as const satisfies FilePanelCandidate;
|
||||
|
||||
export const builtinFilePanels = [
|
||||
markdownPanel,
|
||||
const renderingPanels = [
|
||||
{
|
||||
id: "builtin:markdown",
|
||||
builtin: true,
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
fileKind: "text",
|
||||
},
|
||||
{
|
||||
id: "builtin:html",
|
||||
builtin: true,
|
||||
files: { extensions: ["html", "htm"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
fileKind: "text",
|
||||
},
|
||||
{
|
||||
id: "builtin:image",
|
||||
builtin: true,
|
||||
files: { extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] },
|
||||
document: "bytes",
|
||||
maxBytes: Infinity,
|
||||
fileKind: "image",
|
||||
},
|
||||
] as const satisfies readonly FilePanelCandidate[];
|
||||
|
||||
export function isRenderedMarkdownFile(path: string): boolean {
|
||||
return markdownPanel.files.extensions.some((extension) => extension === filePanelExtension(path));
|
||||
/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */
|
||||
export const sourceFilePanel = {
|
||||
id: "builtin:source",
|
||||
builtin: true,
|
||||
files: "all",
|
||||
fileKind: "text",
|
||||
} as const satisfies FilePanelCandidate;
|
||||
|
||||
export type RenderingFilePanel = (typeof renderingPanels)[number];
|
||||
export type SourceFilePanel = typeof sourceFilePanel;
|
||||
|
||||
/**
|
||||
* Every panel a host offers, in resolver order: the built-ins that render a file, then plugin
|
||||
* panels, then Source. Source is last so it is the default only when nothing else claims the file.
|
||||
*/
|
||||
export function filePanelCatalog<Plugin extends FilePanelCandidate>(
|
||||
pluginPanels: readonly Plugin[],
|
||||
): readonly (RenderingFilePanel | Plugin | SourceFilePanel)[] {
|
||||
return [...renderingPanels, ...pluginPanels, sourceFilePanel];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
|
||||
import { useFilePanelAssociations } from "../associations";
|
||||
import { sourceFilePanel } from "../catalog";
|
||||
import { useFilePanels, type FilePanel } from "../panels";
|
||||
import { resolveFilePanel, type FilePanelFileKind } from "../resolve";
|
||||
import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model";
|
||||
import { getFilePanelChoice, registerFilePanelChoice, subscribeFilePanelChoices } from "./registry";
|
||||
|
||||
export type FilePanelChoice = FilePanelChoiceModel<FilePanel>;
|
||||
|
||||
/**
|
||||
* The file tab's panel: resolves it from the catalog, the device associations and this tab's
|
||||
* choice, and owns the picker and "Open with…" for it.
|
||||
*/
|
||||
export function useFilePanelChoice(input: {
|
||||
serverId: string;
|
||||
targetKey: string | null;
|
||||
path: string;
|
||||
/** The kind the file's stat reports, or null before the stat. */
|
||||
fileKind: FilePanelFileKind | null;
|
||||
/** The tab is on screen. */
|
||||
presentable: boolean;
|
||||
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
|
||||
lineNavigation: number | null;
|
||||
}): { panel: FilePanel | null; choice: FilePanelChoice } {
|
||||
const { targetKey, path, fileKind, presentable, lineNavigation } = input;
|
||||
const { serverId, workspaceId, tabId } = usePaneContext();
|
||||
const candidates = useFilePanels(input.serverId);
|
||||
const { associations, remember } = useFilePanelAssociations();
|
||||
const [choice] = useState(
|
||||
() => new FilePanelChoiceModel<FilePanel>({ remember, linePanelId: sourceFilePanel.id }),
|
||||
);
|
||||
// From the subscribed snapshot: React Compiler caches reads off the stable model object.
|
||||
const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey);
|
||||
const resolution = useMemo(
|
||||
() =>
|
||||
fileKind
|
||||
? resolveFilePanel({
|
||||
file: { path, kind: fileKind },
|
||||
candidates,
|
||||
associations,
|
||||
choice: chosenPanelId,
|
||||
})
|
||||
: null,
|
||||
[associations, candidates, chosenPanelId, fileKind, path],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
choice.observe({ targetKey, resolution, presentable, lineNavigation });
|
||||
}, [choice, lineNavigation, presentable, resolution, targetKey]);
|
||||
useEffect(
|
||||
() => registerFilePanelChoice({ serverId, workspaceId, tabId }, choice),
|
||||
[choice, serverId, tabId, workspaceId],
|
||||
);
|
||||
|
||||
return { panel: resolution?.panel ?? null, choice };
|
||||
}
|
||||
|
||||
const NO_CHOICE_SNAPSHOT: FilePanelChoiceSnapshot<FilePanel> = {
|
||||
targetKey: null,
|
||||
chosenPanelId: null,
|
||||
picker: null,
|
||||
openWith: null,
|
||||
};
|
||||
const subscribeNothing = () => () => {};
|
||||
const getNoChoiceSnapshot = () => NO_CHOICE_SNAPSHOT;
|
||||
|
||||
/** The choice of an open file tab, looked up by the tab's identity, for chrome outside its pane. */
|
||||
export function useTabFilePanelChoice(identity: PanelInstanceIdentity): FilePanelChoice | null {
|
||||
const { serverId, workspaceId, tabId } = identity;
|
||||
const getSnapshot = useCallback(
|
||||
() => getFilePanelChoice({ serverId, workspaceId, tabId }),
|
||||
[serverId, tabId, workspaceId],
|
||||
);
|
||||
return useSyncExternalStore(subscribeFilePanelChoices, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useFilePanelChoiceSnapshot(
|
||||
choice: FilePanelChoice | null,
|
||||
): FilePanelChoiceSnapshot<FilePanel> {
|
||||
return useSyncExternalStore(
|
||||
choice?.subscribe ?? subscribeNothing,
|
||||
choice?.getSnapshot ?? getNoChoiceSnapshot,
|
||||
choice?.getSnapshot ?? getNoChoiceSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filePanelCatalog, sourceFilePanel } from "../catalog";
|
||||
import { resolveFilePanel, type FilePanelCandidate } from "../resolve";
|
||||
import { chosenPanelFor, FilePanelChoiceModel } from "./model";
|
||||
|
||||
const rich: FilePanelCandidate = {
|
||||
id: "plugin:notes/rich",
|
||||
builtin: false,
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
};
|
||||
const markdown = "builtin:markdown";
|
||||
const source = sourceFilePanel.id;
|
||||
|
||||
/** A file tab: resolves against the shared associations and feeds the result to its model. */
|
||||
function openTab(
|
||||
associations: Record<string, string>,
|
||||
candidates: FilePanelCandidate[] = [...filePanelCatalog([rich])],
|
||||
options: { presentable?: boolean; path?: string; lineNavigation?: number | null } = {},
|
||||
) {
|
||||
const model = new FilePanelChoiceModel<FilePanelCandidate>({
|
||||
remember(selectorKey, panelId) {
|
||||
if (panelId === null) delete associations[selectorKey];
|
||||
else associations[selectorKey] = panelId;
|
||||
},
|
||||
linePanelId: source,
|
||||
});
|
||||
let targetKey = `/repo:${options.path ?? "notes.md"}`;
|
||||
let presentable = options.presentable ?? true;
|
||||
let lineNavigation = options.lineNavigation ?? null;
|
||||
const resolve = () =>
|
||||
resolveFilePanel({
|
||||
file: { path: targetKey.split(":")[1]!, kind: "text" },
|
||||
candidates,
|
||||
associations,
|
||||
choice: chosenPanelFor(model.getSnapshot(), targetKey),
|
||||
});
|
||||
// Like the hook: observe, then render what the model's choice resolves to.
|
||||
const render = () => {
|
||||
model.observe({ targetKey, resolution: resolve(), presentable, lineNavigation });
|
||||
return resolve().panel?.id ?? null;
|
||||
};
|
||||
render();
|
||||
return {
|
||||
model,
|
||||
render,
|
||||
panel: () => render(),
|
||||
picker: () => model.getSnapshot().picker,
|
||||
retarget(path: string) {
|
||||
targetKey = `/repo:${path}`;
|
||||
return render();
|
||||
},
|
||||
setPresentable(next: boolean) {
|
||||
presentable = next;
|
||||
return render();
|
||||
},
|
||||
navigateToLine(revision: number) {
|
||||
lineNavigation = revision;
|
||||
return render();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("FilePanelChoiceModel", () => {
|
||||
it("asks once per tab open over the first candidate, and dismissing keeps it", () => {
|
||||
const associations = {};
|
||||
const tab = openTab(associations);
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.picker()).toMatchObject({
|
||||
selectorKey: "ext:md",
|
||||
selectedPanelId: markdown,
|
||||
remember: false,
|
||||
});
|
||||
expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([
|
||||
markdown,
|
||||
rich.id,
|
||||
source,
|
||||
]);
|
||||
tab.model.dismissPicker();
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(associations).toEqual({});
|
||||
|
||||
expect(openTab(associations).picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never asks between built-in panels, and switches between them", () => {
|
||||
const tab = openTab({}, [...filePanelCatalog([])]);
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([
|
||||
markdown,
|
||||
source,
|
||||
]);
|
||||
tab.model.openWith(source);
|
||||
expect(tab.panel()).toBe(source);
|
||||
expect(tab.picker()).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a file only Source claims with Source and nothing to choose", () => {
|
||||
const tab = openTab({}, [...filePanelCatalog([rich])], { path: "index.ts" });
|
||||
expect(tab.panel()).toBe(source);
|
||||
expect(tab.picker()).toBeNull();
|
||||
expect(tab.model.getSnapshot().openWith).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers the choice when Don't ask me again is checked", () => {
|
||||
const associations: Record<string, string> = {};
|
||||
const tab = openTab(associations);
|
||||
tab.model.selectInPicker(rich.id);
|
||||
tab.model.setRemember(true);
|
||||
tab.model.confirmPicker();
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
expect(associations).toEqual({ "ext:md": rich.id });
|
||||
|
||||
const next = openTab(associations);
|
||||
expect(next.panel()).toBe(rich.id);
|
||||
expect(next.picker()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses an unremembered choice for this tab only and asks again on the next open", () => {
|
||||
const associations = {};
|
||||
const tab = openTab(associations);
|
||||
tab.model.selectInPicker(rich.id);
|
||||
tab.model.confirmPicker();
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
expect(associations).toEqual({});
|
||||
|
||||
const next = openTab(associations);
|
||||
expect(next.panel()).toBe(markdown);
|
||||
expect(next.picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not ask with an association or while the tab is in the background", () => {
|
||||
expect(openTab({ "ext:md": rich.id }).picker()).toBeNull();
|
||||
expect(openTab({ "ext:md": source }).panel()).toBe(source);
|
||||
|
||||
const background = openTab({}, undefined, { presentable: false });
|
||||
expect(background.picker()).toBeNull();
|
||||
background.setPresentable(true);
|
||||
expect(background.picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens Source for a line target without asking, and again on each new one", () => {
|
||||
const tab = openTab({}, undefined, { lineNavigation: 1 });
|
||||
expect(tab.panel()).toBe(source);
|
||||
expect(tab.picker()).toBeNull();
|
||||
|
||||
tab.model.openWith(rich.id);
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
expect(tab.navigateToLine(1)).toBe(rich.id);
|
||||
expect(tab.navigateToLine(2)).toBe(source);
|
||||
});
|
||||
|
||||
it("opens Source for the same line target in a retargeted tab", () => {
|
||||
const tab = openTab({}, undefined, { lineNavigation: 1 });
|
||||
tab.model.openWith(markdown);
|
||||
expect(tab.retarget("other.md")).toBe(source);
|
||||
});
|
||||
|
||||
it("starts a new open when the tab is retargeted", () => {
|
||||
const tab = openTab({});
|
||||
tab.model.selectInPicker(rich.id);
|
||||
tab.model.confirmPicker();
|
||||
expect(tab.retarget("other.md")).toBe(markdown);
|
||||
expect(tab.picker()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("switches with Open with and toggles Always use without moving the tab", () => {
|
||||
const associations: Record<string, string> = {};
|
||||
const tab = openTab(associations);
|
||||
tab.model.dismissPicker();
|
||||
expect(tab.model.getSnapshot().openWith).toMatchObject({
|
||||
activePanelId: markdown,
|
||||
remembered: false,
|
||||
});
|
||||
|
||||
tab.model.openWith(rich.id);
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
tab.model.toggleAlwaysUse();
|
||||
expect(associations).toEqual({ "ext:md": rich.id });
|
||||
tab.render();
|
||||
expect(tab.model.getSnapshot().openWith?.remembered).toBe(true);
|
||||
|
||||
tab.model.openWith(markdown);
|
||||
expect(tab.panel()).toBe(markdown);
|
||||
expect(tab.model.getSnapshot().openWith?.remembered).toBe(false);
|
||||
tab.model.openWith(rich.id);
|
||||
tab.render();
|
||||
tab.model.toggleAlwaysUse();
|
||||
expect(associations).toEqual({});
|
||||
expect(tab.panel()).toBe(rich.id);
|
||||
});
|
||||
|
||||
it("asks when a plugin panel arrives after the file first rendered", () => {
|
||||
const candidates: FilePanelCandidate[] = [...filePanelCatalog([])];
|
||||
const tab = openTab({}, candidates);
|
||||
expect(tab.picker()).toBeNull();
|
||||
candidates.splice(1, 0, rich);
|
||||
tab.render();
|
||||
expect(tab.picker()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { asksForFilePanel, type FilePanelCandidate, type FilePanelResolution } from "../resolve";
|
||||
|
||||
/** What the file tab currently resolves; `resolution` is null until the file has been read. */
|
||||
export interface FilePanelChoiceSource<Candidate extends FilePanelCandidate> {
|
||||
targetKey: string | null;
|
||||
resolution: FilePanelResolution<Candidate> | null;
|
||||
/** The tab is on screen, so a picker shown now is seen over the panel it chooses for. */
|
||||
presentable: boolean;
|
||||
/** Identifies the tab's latest request to show a line, or null when it targets no line. */
|
||||
lineNavigation: number | null;
|
||||
}
|
||||
|
||||
export interface FilePanelPickerState<Candidate> {
|
||||
selectorKey: string;
|
||||
candidates: readonly Candidate[];
|
||||
selectedPanelId: string;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
export interface FilePanelOpenWithState<Candidate> {
|
||||
selectorKey: string;
|
||||
candidates: readonly Candidate[];
|
||||
activePanelId: string;
|
||||
/** The active panel is the one remembered for `selectorKey`. */
|
||||
remembered: boolean;
|
||||
}
|
||||
|
||||
export interface FilePanelChoiceSnapshot<Candidate> {
|
||||
targetKey: string | null;
|
||||
/** The panel picked for this tab open. Resolution prefers it over the association. */
|
||||
chosenPanelId: string | null;
|
||||
picker: FilePanelPickerState<Candidate> | null;
|
||||
/** Present only when the file has more than one candidate; drives the bar's panel switcher. */
|
||||
openWith: FilePanelOpenWithState<Candidate> | null;
|
||||
}
|
||||
|
||||
export interface FilePanelChoiceDeps {
|
||||
/** Writes (or clears, with null) the device-wide association for a selector key. */
|
||||
remember(selectorKey: string, panelId: string | null): void;
|
||||
/** The panel a line target opens: Source, the panel that shows lines. */
|
||||
linePanelId: string;
|
||||
}
|
||||
|
||||
/** The tab choice that applies to `targetKey`; a choice never outlives its open. */
|
||||
export function chosenPanelFor(
|
||||
snapshot: FilePanelChoiceSnapshot<unknown>,
|
||||
targetKey: string | null,
|
||||
): string | null {
|
||||
return snapshot.targetKey === targetKey ? snapshot.chosenPanelId : null;
|
||||
}
|
||||
|
||||
const EMPTY: FilePanelChoiceSnapshot<never> = {
|
||||
targetKey: null,
|
||||
chosenPanelId: null,
|
||||
picker: null,
|
||||
openWith: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which file panel one file tab shows, and whether to ask.
|
||||
*
|
||||
* A tab open is one target in one tab: the picker is offered at most once per open, the first
|
||||
* time the tab is on screen with a plugin panel among several candidates and nothing remembered.
|
||||
* Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does.
|
||||
* Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me
|
||||
* again" and "Always use" write the association every later open reads.
|
||||
*/
|
||||
export class FilePanelChoiceModel<Candidate extends FilePanelCandidate> {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FilePanelChoiceSnapshot<Candidate> = EMPTY;
|
||||
private asked = false;
|
||||
private openedLineNavigation: number | null = null;
|
||||
|
||||
constructor(private readonly deps: FilePanelChoiceDeps) {}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FilePanelChoiceSnapshot<Candidate> => this.snapshot;
|
||||
|
||||
observe(source: FilePanelChoiceSource<Candidate>): void {
|
||||
let current = this.snapshot;
|
||||
if (source.targetKey !== current.targetKey) {
|
||||
this.asked = false;
|
||||
this.openedLineNavigation = null;
|
||||
current = { ...EMPTY, targetKey: source.targetKey };
|
||||
}
|
||||
if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) {
|
||||
this.openedLineNavigation = source.lineNavigation;
|
||||
current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null };
|
||||
}
|
||||
const openWith = stableOpenWith(current.openWith, openWithState(source.resolution));
|
||||
let picker = openWith ? current.picker : null;
|
||||
// Decided once per open, the first time there is something to ask about on screen. An
|
||||
// association forgotten while the tab stays open is asked about on the next open.
|
||||
if (!this.asked && source.presentable && openWith && asksForFilePanel(openWith.candidates)) {
|
||||
this.asked = true;
|
||||
const remembered = Boolean(source.resolution?.associatedPanelId);
|
||||
if (!remembered && current.chosenPanelId === null) {
|
||||
picker = {
|
||||
selectorKey: openWith.selectorKey,
|
||||
candidates: openWith.candidates,
|
||||
selectedPanelId: openWith.activePanelId,
|
||||
remember: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.publish({ ...current, openWith, picker });
|
||||
}
|
||||
|
||||
selectInPicker(panelId: string): void {
|
||||
const picker = this.snapshot.picker;
|
||||
if (!picker) return;
|
||||
this.publish({ ...this.snapshot, picker: { ...picker, selectedPanelId: panelId } });
|
||||
}
|
||||
|
||||
setRemember(remember: boolean): void {
|
||||
const picker = this.snapshot.picker;
|
||||
if (!picker) return;
|
||||
this.publish({ ...this.snapshot, picker: { ...picker, remember } });
|
||||
}
|
||||
|
||||
confirmPicker(): void {
|
||||
const picker = this.snapshot.picker;
|
||||
if (!picker) return;
|
||||
if (picker.remember) this.deps.remember(picker.selectorKey, picker.selectedPanelId);
|
||||
this.publish({ ...this.snapshot, chosenPanelId: picker.selectedPanelId, picker: null });
|
||||
}
|
||||
|
||||
/** Keeps whatever the tab shows now; the next open asks again. */
|
||||
dismissPicker(): void {
|
||||
if (!this.snapshot.picker) return;
|
||||
this.publish({ ...this.snapshot, picker: null });
|
||||
}
|
||||
|
||||
openWith(panelId: string): void {
|
||||
this.publish({ ...this.snapshot, chosenPanelId: panelId, picker: null });
|
||||
}
|
||||
|
||||
/** Remembers the active panel, or forgets it when it already is the remembered one. */
|
||||
toggleAlwaysUse(): void {
|
||||
const openWith = this.snapshot.openWith;
|
||||
if (!openWith) return;
|
||||
if (!openWith.remembered) {
|
||||
this.deps.remember(openWith.selectorKey, openWith.activePanelId);
|
||||
return;
|
||||
}
|
||||
// Forgetting must not move the tab off the panel it is showing.
|
||||
this.publish({ ...this.snapshot, chosenPanelId: openWith.activePanelId });
|
||||
this.deps.remember(openWith.selectorKey, null);
|
||||
}
|
||||
|
||||
private publish(next: FilePanelChoiceSnapshot<Candidate>): void {
|
||||
const current = this.snapshot;
|
||||
if (
|
||||
current.targetKey === next.targetKey &&
|
||||
current.chosenPanelId === next.chosenPanelId &&
|
||||
current.picker === next.picker &&
|
||||
current.openWith === next.openWith
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function openWithState<Candidate extends FilePanelCandidate>(
|
||||
resolution: FilePanelResolution<Candidate> | null,
|
||||
): FilePanelOpenWithState<Candidate> | null {
|
||||
if (!resolution?.panel || resolution.candidates.length < 2) return null;
|
||||
return {
|
||||
selectorKey: resolution.selectorKey,
|
||||
candidates: resolution.candidates,
|
||||
activePanelId: resolution.panel.id,
|
||||
remembered: resolution.associatedPanelId === resolution.panel.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps the published object when nothing in it changed, so subscribers do not re-render. */
|
||||
function stableOpenWith<Candidate>(
|
||||
current: FilePanelOpenWithState<Candidate> | null,
|
||||
next: FilePanelOpenWithState<Candidate> | null,
|
||||
): FilePanelOpenWithState<Candidate> | null {
|
||||
if (!current || !next) return next;
|
||||
const same =
|
||||
current.selectorKey === next.selectorKey &&
|
||||
current.activePanelId === next.activePanelId &&
|
||||
current.remembered === next.remembered &&
|
||||
current.candidates.length === next.candidates.length &&
|
||||
current.candidates.every((candidate, index) => candidate === next.candidates[index]);
|
||||
return same ? current : next;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { Ellipsis } from "lucide-react-native";
|
||||
import {
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/menu";
|
||||
import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { FilePanel } from "../panels";
|
||||
import { useFilePanelChoiceSnapshot, useTabFilePanelChoice, type FilePanelChoice } from "./hook";
|
||||
import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./presentation";
|
||||
|
||||
const OPEN_WITH_PAGE_ID = "filePanelOpenWith";
|
||||
const MENU_ICON_SIZE = 16;
|
||||
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const NO_PAGES: readonly MenuPageDefinition[] = [];
|
||||
|
||||
export interface FilePanelOpenWithMenu {
|
||||
/** The "Open with…" row, or null when the file has one panel or none. */
|
||||
trigger: ReactElement | null;
|
||||
/** The page the row opens; pass to the menu surface. */
|
||||
pages: readonly MenuPageDefinition[];
|
||||
}
|
||||
|
||||
/** "Open with…" for a file tab's context menu, found by the tab's identity. */
|
||||
export function useFilePanelOpenWithMenu(
|
||||
identity: PanelInstanceIdentity,
|
||||
testIDBase: string,
|
||||
): FilePanelOpenWithMenu {
|
||||
return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless
|
||||
* the file has more than one panel.
|
||||
*/
|
||||
export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) {
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
if (!openWith) return null;
|
||||
const label = t("panels.file.filePanels.actions");
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton kind="menu" label={label} compact={compact} testID="file-panel-bar-menu">
|
||||
<ThemedEllipsis
|
||||
size={paneContentToolbarIconSize(compact)}
|
||||
uniProps={extraMutedIconColorMapping}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={240}
|
||||
sheetTitle={label}
|
||||
testID="file-panel-bar-menu-surface"
|
||||
>
|
||||
<AlwaysUseItem
|
||||
choice={choice}
|
||||
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
|
||||
selected={openWith.remembered}
|
||||
testID="file-panel-bar-menu-always-use"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function useOpenWithMenu(
|
||||
choice: FilePanelChoice | null,
|
||||
testIDBase: string,
|
||||
): FilePanelOpenWithMenu {
|
||||
const { t } = useTranslation();
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
return useMemo(() => {
|
||||
if (!choice || !openWith) return { trigger: null, pages: NO_PAGES };
|
||||
const title = t("panels.file.filePanels.openWith");
|
||||
return {
|
||||
trigger: (
|
||||
<MenuSubTrigger id={OPEN_WITH_PAGE_ID} testID={`${testIDBase}-open-with`}>
|
||||
{title}
|
||||
</MenuSubTrigger>
|
||||
),
|
||||
pages: [
|
||||
{
|
||||
id: OPEN_WITH_PAGE_ID,
|
||||
title,
|
||||
content: (
|
||||
<>
|
||||
{openWith.candidates.map((panel) => (
|
||||
<OpenWithItem
|
||||
key={panel.id}
|
||||
choice={choice}
|
||||
panel={panel}
|
||||
label={filePanelOptionLabel(t, panel)}
|
||||
selected={panel.id === openWith.activePanelId}
|
||||
testID={`${testIDBase}-open-with-${panel.id}`}
|
||||
/>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<AlwaysUseItem
|
||||
choice={choice}
|
||||
label={filePanelAlwaysUseLabel(t, openWith.selectorKey)}
|
||||
selected={openWith.remembered}
|
||||
testID={`${testIDBase}-always-use`}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [choice, openWith, t, testIDBase]);
|
||||
}
|
||||
|
||||
function OpenWithItem({
|
||||
choice,
|
||||
panel,
|
||||
label,
|
||||
selected,
|
||||
testID,
|
||||
}: {
|
||||
choice: FilePanelChoice;
|
||||
panel: FilePanel;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
testID: string;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => choice.openWith(panel.id), [choice, panel.id]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPanelIcon
|
||||
icon={filePanelIcon(panel)}
|
||||
size={MENU_ICON_SIZE}
|
||||
uniProps={mutedIconMapping}
|
||||
/>
|
||||
),
|
||||
[panel],
|
||||
);
|
||||
return (
|
||||
<MenuItem selected={selected} leading={leading} onSelect={handleSelect} testID={testID}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function AlwaysUseItem({
|
||||
choice,
|
||||
label,
|
||||
selected,
|
||||
testID,
|
||||
}: {
|
||||
choice: FilePanelChoice;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
testID: string;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => choice.toggleAlwaysUse(), [choice]);
|
||||
return (
|
||||
<MenuItem selected={selected} onSelect={handleSelect} testID={testID}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { FilePanel } from "../panels";
|
||||
import type { FilePanelPickerState } from "./model";
|
||||
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
|
||||
import { filePanelIcon, filePanelOptionLabel, filePanelPickerTitle } from "./presentation";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** The first-open question for a file with several panels, over the panel already showing. */
|
||||
export function FilePanelPicker({ choice }: { choice: FilePanelChoice }) {
|
||||
const { t } = useTranslation();
|
||||
const { picker } = useFilePanelChoiceSnapshot(choice);
|
||||
// The sheet animates out after the model has closed the picker; keep drawing what it asked.
|
||||
const [shown, setShown] = useState(picker);
|
||||
if (picker && picker !== shown) setShown(picker);
|
||||
const content = picker ?? shown;
|
||||
|
||||
const dismiss = useCallback(() => choice.dismissPicker(), [choice]);
|
||||
const confirm = useCallback(() => choice.confirmPicker(), [choice]);
|
||||
const toggleRemember = useCallback(
|
||||
() => choice.setRemember(!choice.getSnapshot().picker?.remember),
|
||||
[choice],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: content ? filePanelPickerTitle(t, content.selectorKey) : "" }),
|
||||
[content, t],
|
||||
);
|
||||
const footer = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
style={styles.footerButton}
|
||||
variant="default"
|
||||
onPress={confirm}
|
||||
testID="file-panel-picker-use"
|
||||
>
|
||||
{t("panels.file.filePanels.use")}
|
||||
</Button>
|
||||
),
|
||||
[confirm, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={picker !== null}
|
||||
onClose={dismiss}
|
||||
footer={footer}
|
||||
snapPoints={PICKER_SNAP_POINTS}
|
||||
desktopMaxWidth={420}
|
||||
testID="file-panel-picker"
|
||||
>
|
||||
{content ? (
|
||||
<PickerBody choice={choice} picker={content} onToggleRemember={toggleRemember} />
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const PICKER_SNAP_POINTS = ["60%", "90%"];
|
||||
|
||||
function PickerBody({
|
||||
choice,
|
||||
picker,
|
||||
onToggleRemember,
|
||||
}: {
|
||||
choice: FilePanelChoice;
|
||||
picker: FilePanelPickerState<FilePanel>;
|
||||
onToggleRemember: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const select = useCallback((panelId: string) => choice.selectInPicker(panelId), [choice]);
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.card} accessibilityRole="radiogroup">
|
||||
{picker.candidates.map((panel, index) => (
|
||||
<PanelRadioRow
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
label={filePanelOptionLabel(t, panel)}
|
||||
selected={panel.id === picker.selectedPanelId}
|
||||
withBorder={index > 0}
|
||||
onSelect={select}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<CheckboxRow
|
||||
label={t("panels.file.filePanels.dontAskAgain")}
|
||||
checked={picker.remember}
|
||||
onToggle={onToggleRemember}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelRadioRow({
|
||||
panel,
|
||||
label,
|
||||
selected,
|
||||
withBorder,
|
||||
onSelect,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
withBorder: boolean;
|
||||
onSelect: (panelId: string) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
|
||||
const accessibilityState = useMemo(() => ({ checked: selected }), [selected]);
|
||||
const rowStyle = useMemo(
|
||||
() => [settingsStyles.row, withBorder ? settingsStyles.rowBorder : null, styles.optionRow],
|
||||
[withBorder],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={selected}
|
||||
>
|
||||
<View style={selected ? styles.radioSelected : styles.radio}>
|
||||
{selected ? <View style={styles.radioDot} /> : null}
|
||||
</View>
|
||||
<ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxRow({
|
||||
label,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const accessibilityState = useMemo(() => ({ checked }), [checked]);
|
||||
return (
|
||||
<PressHighlight
|
||||
style={styles.checkboxRow}
|
||||
onPress={onToggle}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={checked}
|
||||
testID="file-panel-picker-remember"
|
||||
>
|
||||
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
|
||||
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
footerButton: {
|
||||
flex: 1,
|
||||
},
|
||||
optionRow: {
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
radio: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
radioSelected: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
radioDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
checkboxRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
checkbox: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
checkboxChecked: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { PluginIcon } from "@getpaseo/plugin/client";
|
||||
import type { FilePanel } from "../panels";
|
||||
import { parseFilePanelSelectorKey } from "../resolve";
|
||||
|
||||
const BUILTIN_PRESENTATION = {
|
||||
"builtin:markdown": { title: "markdown", icon: "FileText" },
|
||||
"builtin:html": { title: "html", icon: "FileCode" },
|
||||
"builtin:image": { title: "image", icon: "Image" },
|
||||
"builtin:source": { title: "source", icon: "Code" },
|
||||
} as const;
|
||||
|
||||
export function filePanelIcon(panel: FilePanel): PluginIcon {
|
||||
return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon;
|
||||
}
|
||||
|
||||
/** "Markdown", "Rich Markdown": the panel switcher's label. */
|
||||
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
|
||||
return panel.builtin
|
||||
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
|
||||
: panel.contribution.title;
|
||||
}
|
||||
|
||||
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
|
||||
export function filePanelOptionLabel(t: TFunction, panel: FilePanel): string {
|
||||
return t("panels.file.filePanels.option", {
|
||||
title: filePanelTitle(t, panel),
|
||||
owner: panel.builtin ? t("panels.file.filePanels.builtin.owner") : panel.plugin.id,
|
||||
});
|
||||
}
|
||||
|
||||
/** ".md" for an extension key, "Dockerfile" for a filename key. */
|
||||
export function filePanelSelectorLabel(selectorKey: string): string {
|
||||
const selector = parseFilePanelSelectorKey(selectorKey);
|
||||
return selector.kind === "name" ? selector.value : `.${selector.value}`;
|
||||
}
|
||||
|
||||
/** "Open .md files with" for an extension key, "Open Dockerfile with" for a filename key. */
|
||||
export function filePanelPickerTitle(t: TFunction, selectorKey: string): string {
|
||||
const selector = parseFilePanelSelectorKey(selectorKey);
|
||||
return selector.kind === "name"
|
||||
? t("panels.file.filePanels.pickerTitleFilename", { filename: selector.value })
|
||||
: t("panels.file.filePanels.pickerTitleExtension", { extension: selector.value });
|
||||
}
|
||||
|
||||
/** "Always use for .md", "Always use for Dockerfile". */
|
||||
export function filePanelAlwaysUseLabel(t: TFunction, selectorKey: string): string {
|
||||
const selector = parseFilePanelSelectorKey(selectorKey);
|
||||
return selector.kind === "name"
|
||||
? t("panels.file.filePanels.alwaysUseFilename", { filename: selector.value })
|
||||
: t("panels.file.filePanels.alwaysUseExtension", { extension: selector.value });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
buildPanelInstanceKey,
|
||||
type PanelInstanceIdentity,
|
||||
} from "@/panels/panel-instance-attributes";
|
||||
import type { FilePanel } from "../panels";
|
||||
import type { FilePanelChoiceModel } from "./model";
|
||||
|
||||
/**
|
||||
* The open file tabs' choice models, so tab chrome outside the pane (the tab's context menu)
|
||||
* reads and drives the same choice the pane renders.
|
||||
*/
|
||||
const models = new Map<string, FilePanelChoiceModel<FilePanel>>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function registerFilePanelChoice(
|
||||
identity: PanelInstanceIdentity,
|
||||
model: FilePanelChoiceModel<FilePanel>,
|
||||
): () => void {
|
||||
const key = buildPanelInstanceKey(identity);
|
||||
models.set(key, model);
|
||||
notify();
|
||||
return () => {
|
||||
if (models.get(key) !== model) return;
|
||||
models.delete(key);
|
||||
notify();
|
||||
};
|
||||
}
|
||||
|
||||
export function getFilePanelChoice(
|
||||
identity: PanelInstanceIdentity,
|
||||
): FilePanelChoiceModel<FilePanel> | null {
|
||||
return models.get(buildPanelInstanceKey(identity)) ?? null;
|
||||
}
|
||||
|
||||
export function subscribeFilePanelChoices(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePanelOpenWithState } from "./model";
|
||||
import { filePanelSwitcherState } from "./switcher-layout";
|
||||
|
||||
function openWith(count: number): FilePanelOpenWithState<{ id: string }> {
|
||||
const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` }));
|
||||
return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false };
|
||||
}
|
||||
|
||||
describe("filePanelSwitcherState", () => {
|
||||
it("shows nothing when the file has one panel or none", () => {
|
||||
expect(filePanelSwitcherState(null, { compact: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("sets up to three panels side by side and more behind a menu", () => {
|
||||
const wide = { compact: false };
|
||||
expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({
|
||||
layout: "menu",
|
||||
activePanelId: "panel-1",
|
||||
candidates: openWith(4).candidates,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets two panels side by side on a compact screen and more behind a menu", () => {
|
||||
const compact = { compact: true };
|
||||
expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented");
|
||||
expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FilePanelOpenWithState } from "./model";
|
||||
|
||||
/** Up to this many panels sit side by side in the bar; more go behind a menu. */
|
||||
const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 };
|
||||
|
||||
export interface FilePanelSwitcherState<Candidate> {
|
||||
layout: "segmented" | "menu";
|
||||
candidates: readonly Candidate[];
|
||||
activePanelId: string;
|
||||
}
|
||||
|
||||
/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */
|
||||
export function filePanelSwitcherState<Candidate>(
|
||||
openWith: FilePanelOpenWithState<Candidate> | null,
|
||||
{ compact }: { compact: boolean },
|
||||
): FilePanelSwitcherState<Candidate> | null {
|
||||
if (!openWith) return null;
|
||||
const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide;
|
||||
return {
|
||||
layout: openWith.candidates.length > limit ? "menu" : "segmented",
|
||||
candidates: openWith.candidates,
|
||||
activePanelId: openWith.activePanelId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { MenuItem } from "@/components/ui/menu";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
ToolbarLabelTriggerIcon,
|
||||
isToolbarLabelTriggerHighlighted,
|
||||
toolbarLabelTriggerStyle,
|
||||
toolbarLabelTriggerTextStyle,
|
||||
} from "@/components/ui/toolbar-label-trigger";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { FilePanel } from "../panels";
|
||||
import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook";
|
||||
import { filePanelIcon, filePanelTitle } from "./presentation";
|
||||
import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */
|
||||
export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) {
|
||||
const { openWith } = useFilePanelChoiceSnapshot(choice);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const switcher = filePanelSwitcherState(openWith, { compact });
|
||||
const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]);
|
||||
if (!switcher) return null;
|
||||
return switcher.layout === "segmented" ? (
|
||||
<SegmentedSwitcher switcher={switcher} onSelect={select} />
|
||||
) : (
|
||||
<MenuSwitcher switcher={switcher} onSelect={select} />
|
||||
);
|
||||
}
|
||||
|
||||
interface SwitcherProps {
|
||||
switcher: FilePanelSwitcherState<FilePanel>;
|
||||
onSelect(panelId: string): void;
|
||||
}
|
||||
|
||||
function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
switcher.candidates.map((panel) => ({
|
||||
value: panel.id,
|
||||
label: filePanelTitle(t, panel),
|
||||
testID: `file-panel-switcher-${panel.id}`,
|
||||
})),
|
||||
[switcher.candidates, t],
|
||||
);
|
||||
return (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={switcher.activePanelId}
|
||||
onValueChange={onSelect}
|
||||
options={options}
|
||||
testID="file-panel-switcher"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSwitcher({ switcher, onSelect }: SwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId);
|
||||
const activeTitle = active ? filePanelTitle(t, active) : "";
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={toolbarLabelTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("panels.file.filePanels.openWith")}
|
||||
testID="file-panel-switcher"
|
||||
>
|
||||
{(state) => (
|
||||
<>
|
||||
<Text
|
||||
style={toolbarLabelTriggerTextStyle(isToolbarLabelTriggerHighlighted(state))}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{activeTitle}
|
||||
</Text>
|
||||
<ToolbarLabelTriggerIcon>
|
||||
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
|
||||
</ToolbarLabelTriggerIcon>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} testID="file-panel-switcher-menu">
|
||||
{switcher.candidates.map((panel) => (
|
||||
<SwitcherMenuItem
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
selected={panel.id === switcher.activePanelId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherMenuItem({
|
||||
panel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
selected: boolean;
|
||||
onSelect(panelId: string): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedPanelIcon icon={filePanelIcon(panel)} size={16} uniProps={mutedIconMapping} />,
|
||||
[panel],
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
testID={`file-panel-switcher-${panel.id}`}
|
||||
>
|
||||
{filePanelTitle(t, panel)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const frameStyle = { flex: 1, backgroundColor: "white" };
|
||||
const originWhitelist = ["*"];
|
||||
|
||||
/** What Android's WebView shows inline. It has no viewer for PDF or other documents. */
|
||||
const ANDROID_INLINE_EXTENSIONS = new Set([
|
||||
"html",
|
||||
"htm",
|
||||
"xhtml",
|
||||
"xml",
|
||||
"svg",
|
||||
"txt",
|
||||
"json",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"avif",
|
||||
]);
|
||||
|
||||
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
|
||||
const source = useMemo(() => ({ uri: url }), [url]);
|
||||
const loaded = useRef<string | null>(null);
|
||||
const allowDocument = useCallback(
|
||||
(request: { url: string }) => {
|
||||
if (!url.startsWith("file:") || request.url !== url || loaded.current === url) return false;
|
||||
loaded.current = url;
|
||||
return true;
|
||||
},
|
||||
[url],
|
||||
);
|
||||
if (!url.startsWith("file:")) return null;
|
||||
// The host names a file URL after the file, so its extension is the document's type.
|
||||
const extension = urlExtension(url);
|
||||
if (Platform.OS === "android" && !ANDROID_INLINE_EXTENSIONS.has(extension)) {
|
||||
return <UnsupportedDocument extension={extension} testID={testID} />;
|
||||
}
|
||||
return (
|
||||
<WebView
|
||||
testID={testID}
|
||||
style={frameStyle}
|
||||
source={source}
|
||||
originWhitelist={originWhitelist}
|
||||
onShouldStartLoadWithRequest={allowDocument}
|
||||
javaScriptEnabled={false}
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={false}
|
||||
domStorageEnabled={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
allowFileAccess
|
||||
allowFileAccessFromFileURLs={false}
|
||||
allowUniversalAccessFromFileURLs={false}
|
||||
allowingReadAccessToURL={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UnsupportedDocument({ extension, testID }: { extension: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID={testID}>
|
||||
<Text style={styles.mutedText}>
|
||||
{t("panels.file.documentUnsupported", { type: extension.toUpperCase() })}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function urlExtension(url: string): string {
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot < 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { documentFrameSandbox } from "./sandbox";
|
||||
|
||||
const frameStyle = { flex: 1, minHeight: 0, border: "none", backgroundColor: "white" } as const;
|
||||
|
||||
interface ResolvedDocument {
|
||||
url: string;
|
||||
sandbox: string | null;
|
||||
}
|
||||
|
||||
export function DocumentFrame({ url, testID }: { url: string; testID?: string }) {
|
||||
const [document, setDocument] = useState<ResolvedDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url.startsWith("blob:")) return;
|
||||
const lifetime = new AbortController();
|
||||
void fetch(url, { signal: lifetime.signal })
|
||||
.then(async (response) => {
|
||||
const sandbox = documentFrameSandbox(response.headers.get("content-type"));
|
||||
await response.body?.cancel();
|
||||
if (!lifetime.signal.aborted) setDocument({ url, sandbox });
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!lifetime.signal.aborted) setDocument({ url, sandbox: "" });
|
||||
});
|
||||
return () => lifetime.abort();
|
||||
}, [url]);
|
||||
|
||||
if (!url.startsWith("blob:") || document?.url !== url) return null;
|
||||
return (
|
||||
<iframe
|
||||
title="Document preview"
|
||||
data-testid={testID}
|
||||
src={url}
|
||||
sandbox={document.sandbox ?? undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
style={frameStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { documentFrameSandbox } from "./sandbox";
|
||||
|
||||
describe("document frame sandbox", () => {
|
||||
it("lets the browser's PDF viewer render PDF documents", () => {
|
||||
expect(documentFrameSandbox("application/pdf")).toBeNull();
|
||||
expect(documentFrameSandbox("Application/PDF; charset=binary")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["text/html", "application/octet-stream", "unknown", "", null, undefined])(
|
||||
"keeps %s fully sandboxed",
|
||||
(mediaType) => {
|
||||
expect(documentFrameSandbox(mediaType)).toBe("");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Chromium refuses to run its PDF viewer in a sandboxed frame, regardless of tokens.
|
||||
* The viewer is a browser component, not page script. The host sets the blob's type from
|
||||
* the daemon's MIME type, so HTML bytes labeled PDF fail to parse in the viewer rather
|
||||
* than executing. HTML and every other media type retain the full sandbox.
|
||||
*/
|
||||
export function documentFrameSandbox(mediaType: string | null | undefined): string | null {
|
||||
return mediaType?.split(";")[0].trim().toLowerCase() === "application/pdf" ? null : "";
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
persistAttachmentFromBytes,
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import type { FileKind, FileObservation, FileSession } from "./handle";
|
||||
|
||||
export interface DaemonFileFeatures {
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: boolean;
|
||||
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
|
||||
fileByteWrites: boolean;
|
||||
}
|
||||
|
||||
/** A file session over a daemon connection. */
|
||||
export function createDaemonFileSession(
|
||||
client: DaemonClient,
|
||||
features: DaemonFileFeatures,
|
||||
): FileSession {
|
||||
return {
|
||||
async observe(target, onObservation) {
|
||||
if (!features.workspaceFileEditing) {
|
||||
// COMPAT(workspaceFileEditing): the host cannot observe files; its read is the stat.
|
||||
return {
|
||||
initial: statFromRead(await client.readFile(target.cwd, target.path)),
|
||||
unsubscribe() {},
|
||||
};
|
||||
}
|
||||
let kind: { kind: FileKind; mimeType: string } | null = null;
|
||||
const classify = (version: FileVersion): FileObservation => {
|
||||
if (version.status !== "ready") return version;
|
||||
if (version.kind && version.mimeType) {
|
||||
kind = { kind: version.kind, mimeType: version.mimeType };
|
||||
}
|
||||
// COMPAT(fileVersionKind): added in v0.13.0, remove after 2027-04-20. Older daemons
|
||||
// classify a file only when reading it; the first read's kind stands for later versions.
|
||||
if (!kind) return { ...version, status: "error", error: "The file has no kind." };
|
||||
return { ...version, ...kind };
|
||||
};
|
||||
const subscription = await client.subscribeFile(target, (version) =>
|
||||
onObservation(classify(version)),
|
||||
);
|
||||
const initial = subscription.initial;
|
||||
if (initial.status === "ready" && !initial.kind) {
|
||||
// COMPAT(fileVersionKind): see above.
|
||||
const read = statFromRead(await client.readFile(target.cwd, target.path));
|
||||
kind = { kind: read.kind, mimeType: read.mimeType };
|
||||
}
|
||||
return {
|
||||
initial: classify(initial),
|
||||
unsubscribe() {
|
||||
void subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
},
|
||||
async read(target) {
|
||||
return client.readFile(target.cwd, target.path);
|
||||
},
|
||||
async write({ target, content, ...expected }) {
|
||||
if (!features.workspaceFileEditing) throw new Error("Update the host to edit files.");
|
||||
if (typeof content === "string") {
|
||||
return client.writeFile({ ...target, content, ...expected });
|
||||
}
|
||||
if (!features.fileByteWrites) throw new Error("Update the host to write binary files.");
|
||||
return client.writeFile({
|
||||
...target,
|
||||
content: bytesToBase64(content),
|
||||
contentEncoding: "base64",
|
||||
...expected,
|
||||
});
|
||||
},
|
||||
async createUrl({ target, bytes, mimeType }) {
|
||||
const attachment = await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType,
|
||||
path: `${target.cwd}/${target.path}`,
|
||||
contentLength: bytes.byteLength,
|
||||
contentKey: String(Date.now()),
|
||||
}),
|
||||
bytes,
|
||||
mimeType,
|
||||
fileName: getFileNameFromPath(target.path),
|
||||
});
|
||||
const url = await resolveAttachmentPreviewUrl(attachment);
|
||||
return {
|
||||
url,
|
||||
release() {
|
||||
void releaseAttachmentPreviewUrl({ attachment, url });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function statFromRead(file: FileReadResult): Extract<FileObservation, { status: "ready" }> {
|
||||
return {
|
||||
status: "ready",
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
kind: file.kind,
|
||||
mimeType: file.mime,
|
||||
};
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
PluginFileDeletedError,
|
||||
type PluginFileEvent,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileHandle } from "./handle";
|
||||
import { InMemoryFileSession } from "./testing";
|
||||
|
||||
async function openFile(content: string | Uint8Array = "first\n") {
|
||||
const session = new InMemoryFileSession();
|
||||
session.put("notes.md", content);
|
||||
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
|
||||
handle.setObserving(true);
|
||||
await settle();
|
||||
const events: PluginFileEvent["type"][] = [];
|
||||
handle.file.watch((event) => events.push(event.type));
|
||||
return { session, handle, file: handle.file, events };
|
||||
}
|
||||
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("FileHandle", () => {
|
||||
it("describes the file from its stat before anything reads it", async () => {
|
||||
const { handle, file } = await openFile("# Notes\n");
|
||||
|
||||
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text", size: 8 });
|
||||
expect([file.path, file.name, file.extension, file.mimeType, file.size]).toEqual([
|
||||
"/repo/notes.md",
|
||||
"notes.md",
|
||||
"md",
|
||||
"text/plain",
|
||||
8,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes over the version it read and refreshes the stat", async () => {
|
||||
const { session, file, events } = await openFile();
|
||||
|
||||
expect(await file.readText()).toBe("first\n");
|
||||
await file.writeText("second, longer\n");
|
||||
|
||||
expect(session.text("notes.md")).toBe("second, longer\n");
|
||||
expect(file.size).toBe(15);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a write after an external change unless forced", async () => {
|
||||
const { session, file, events } = await openFile();
|
||||
await file.readText();
|
||||
|
||||
session.put("notes.md", "external\n");
|
||||
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileConflictError);
|
||||
expect(session.text("notes.md")).toBe("external\n");
|
||||
|
||||
await file.writeText("mine\n", { force: true });
|
||||
expect(session.text("notes.md")).toBe("mine\n");
|
||||
expect(events).toEqual(["changed"]);
|
||||
});
|
||||
|
||||
it("rejects a write over a deleted file it has not observed yet", async () => {
|
||||
const { session, handle, file } = await openFile();
|
||||
await file.readText();
|
||||
handle.setObserving(false);
|
||||
session.remove("notes.md");
|
||||
|
||||
await expect(file.writeText("mine\n")).rejects.toBeInstanceOf(PluginFileDeletedError);
|
||||
await expect(file.writeText("mine\n", { force: true })).rejects.toBeInstanceOf(
|
||||
PluginFileDeletedError,
|
||||
);
|
||||
});
|
||||
|
||||
it("forces over a change it has not observed yet", async () => {
|
||||
const { session, handle, file } = await openFile();
|
||||
await file.readText();
|
||||
handle.setObserving(false);
|
||||
session.put("notes.md", "unobserved\n");
|
||||
|
||||
await file.writeText("mine\n", { force: true });
|
||||
|
||||
expect(session.text("notes.md")).toBe("mine\n");
|
||||
});
|
||||
|
||||
it("keeps a byte order mark it read", async () => {
|
||||
const { session, file } = await openFile(
|
||||
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("bom\r\n")]),
|
||||
);
|
||||
|
||||
expect(await file.readText()).toBe("bom\r\n");
|
||||
await file.writeText("saved\r\n");
|
||||
|
||||
expect(session.text("notes.md")).toBe("saved\r\n");
|
||||
});
|
||||
|
||||
it("tells watchers about each external version once, and about deletion", async () => {
|
||||
const { session, file, events } = await openFile();
|
||||
|
||||
session.put("notes.md", "external\n");
|
||||
await file.readText();
|
||||
session.remove("notes.md");
|
||||
session.remove("notes.md");
|
||||
await settle();
|
||||
session.put("notes.md", "restored\n");
|
||||
|
||||
expect(events).toEqual(["changed", "deleted", "changed"]);
|
||||
});
|
||||
|
||||
it("keeps the stat of a deleted file, so its panel stays mounted", async () => {
|
||||
const { session, handle } = await openFile();
|
||||
|
||||
session.remove("notes.md");
|
||||
await settle();
|
||||
|
||||
expect(handle.getSnapshot()).toMatchObject({ status: "ready", kind: "text" });
|
||||
});
|
||||
|
||||
it("does not report a deletion when the file is back before a read confirms it", async () => {
|
||||
const { session, events } = await openFile();
|
||||
|
||||
session.remove("notes.md");
|
||||
session.put("notes.md", "first\n");
|
||||
await settle();
|
||||
|
||||
expect(events).toEqual(["changed"]);
|
||||
});
|
||||
|
||||
it("reports what changed while the tab was off screen", async () => {
|
||||
const { session, handle, events } = await openFile();
|
||||
|
||||
handle.setObserving(false);
|
||||
session.put("notes.md", "while hidden\n");
|
||||
expect(events).toEqual([]);
|
||||
handle.setObserving(true);
|
||||
await settle();
|
||||
|
||||
expect(events).toEqual(["changed"]);
|
||||
expect(session.observerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("reads after its own pending writes", async () => {
|
||||
const { file } = await openFile();
|
||||
await file.readText();
|
||||
|
||||
const write = file.writeText("flushed\n");
|
||||
const read = file.readText();
|
||||
|
||||
await write;
|
||||
expect(await read).toBe("flushed\n");
|
||||
});
|
||||
|
||||
it("holds writes while the tab asks to discard them, and drops them when it closes", async () => {
|
||||
const { session, handle, file } = await openFile();
|
||||
await file.readText();
|
||||
|
||||
const resume = handle.holdWrites();
|
||||
const kept = file.writeText("kept\n");
|
||||
await settle();
|
||||
expect(session.text("notes.md")).toBe("first\n");
|
||||
resume();
|
||||
await kept;
|
||||
expect(session.text("notes.md")).toBe("kept\n");
|
||||
|
||||
handle.holdWrites();
|
||||
const dropped = file.writeText("dropped\n");
|
||||
handle.dispose();
|
||||
await expect(dropped).rejects.toThrow("The file tab was closed.");
|
||||
expect(session.text("notes.md")).toBe("kept\n");
|
||||
});
|
||||
|
||||
it("owns one URL per version and releases it when replaced or closed", async () => {
|
||||
const { session, handle, file } = await openFile();
|
||||
|
||||
const first = await file.url();
|
||||
expect(await file.url()).toBe(first);
|
||||
session.put("notes.md", "changed\n");
|
||||
const second = await file.url();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(session.createdUrls).toEqual([first, second]);
|
||||
expect(session.releasedUrls).toEqual([first]);
|
||||
handle.dispose();
|
||||
expect(session.releasedUrls).toEqual([first, second]);
|
||||
});
|
||||
|
||||
it("writes bytes", async () => {
|
||||
const { session, file } = await openFile();
|
||||
await file.readBytes();
|
||||
|
||||
await file.writeBytes(new Uint8Array([1, 2, 3]));
|
||||
|
||||
expect(session.writes).toEqual(["bytes:3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
PluginFileDeletedError,
|
||||
type PluginFile,
|
||||
type PluginFileEvent,
|
||||
type PluginFileWriteOptions,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
import { decodeUtf8 } from "@/utils/decode-utf8";
|
||||
|
||||
export interface FileTarget {
|
||||
cwd: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type FileKind = "text" | "image" | "binary";
|
||||
|
||||
/** A version the session has classified, so a panel can be chosen before the file is read. */
|
||||
export interface FileStat {
|
||||
status: "ready";
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
revision?: string;
|
||||
kind: FileKind;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export type FileObservation =
|
||||
| FileStat
|
||||
| Extract<FileVersion, { status: "missing" }>
|
||||
| Extract<FileVersion, { status: "error" }>;
|
||||
|
||||
export interface FileContents {
|
||||
bytes: Uint8Array;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
revision?: string;
|
||||
}
|
||||
|
||||
/** What a handle needs from the host. The daemon implementation lives in `./daemon-session`. */
|
||||
export interface FileSession {
|
||||
observe(
|
||||
target: FileTarget,
|
||||
onObservation: (observation: FileObservation) => void,
|
||||
): Promise<{ initial: FileObservation; unsubscribe(): void }>;
|
||||
read(target: FileTarget): Promise<FileContents>;
|
||||
write(input: {
|
||||
target: FileTarget;
|
||||
content: string | Uint8Array;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult>;
|
||||
createUrl(input: { target: FileTarget; bytes: Uint8Array; mimeType: string }): Promise<{
|
||||
url: string;
|
||||
release(): void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type FileHandleSnapshot =
|
||||
| { status: "loading" }
|
||||
| FileStat
|
||||
| { status: "missing" }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
interface Version {
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
revision?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file tab target's file: its stat for choosing a panel, and the `PluginFile` every panel
|
||||
* reads and writes through.
|
||||
*
|
||||
* The handle remembers the version it last read or wrote, and writes only over that version.
|
||||
* Changes on disk reach `watch` listeners once per version; versions this handle read or wrote
|
||||
* itself do not. Reads wait for this handle's writes, so a panel that mounts while another flushes
|
||||
* reads the flushed file. Writes run one at a time, and wait while the tab holds them.
|
||||
*
|
||||
* Once the file has a stat, the snapshot keeps it: the mounted panel keeps what it shows, and
|
||||
* learns about a deletion from `watch`. A deletion is reported only after a read confirms it, so a
|
||||
* program that removes the file and writes it back does not look like one.
|
||||
*/
|
||||
export class FileHandle {
|
||||
readonly file: PluginFile;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly watchers = new Set<(event: PluginFileEvent) => void>();
|
||||
private snapshot: FileHandleSnapshot = { status: "loading" };
|
||||
private stat: FileStat | null = null;
|
||||
private latest: FileObservation | null = null;
|
||||
private base: Version | null = null;
|
||||
/** Versions this handle has read, written, or told watchers about. */
|
||||
private readonly seen = new Set<string>();
|
||||
private deleted = false;
|
||||
private confirmingDeletion = false;
|
||||
private hasBom = false;
|
||||
private writes: Promise<unknown> = Promise.resolve();
|
||||
private writing = 0;
|
||||
private holds = 0;
|
||||
private releaseHold: (() => void) | null = null;
|
||||
private held: Promise<void> | null = null;
|
||||
private observing: { generation: number; unsubscribe: (() => void) | null } | null = null;
|
||||
private generation = 0;
|
||||
private currentUrl: { key: string; url: Promise<string>; release: () => void } | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly session: FileSession,
|
||||
private readonly target: FileTarget,
|
||||
absolutePath: string,
|
||||
) {
|
||||
const stat = () => this.stat;
|
||||
const name = absolutePath.split(/[\\/]/).pop() ?? "";
|
||||
const dot = name.lastIndexOf(".");
|
||||
this.file = {
|
||||
path: absolutePath,
|
||||
name,
|
||||
extension: dot < 0 ? "" : name.slice(dot + 1).toLowerCase(),
|
||||
get size() {
|
||||
return stat()?.size ?? 0;
|
||||
},
|
||||
get modifiedAt() {
|
||||
return stat()?.modifiedAt ?? "";
|
||||
},
|
||||
get mimeType() {
|
||||
return stat()?.mimeType ?? "application/octet-stream";
|
||||
},
|
||||
readText: () => this.readText(),
|
||||
readBytes: () => this.readBytes(),
|
||||
url: () => this.url(),
|
||||
writeText: (text, options) => this.writeText(text, options),
|
||||
writeBytes: (bytes, options) => this.write(bytes, options),
|
||||
watch: (listener) => {
|
||||
this.watchers.add(listener);
|
||||
return () => this.watchers.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FileHandleSnapshot => this.snapshot;
|
||||
|
||||
/** Observes the file while the tab is on screen. Coming back reports what changed meanwhile. */
|
||||
setObserving(observing: boolean): void {
|
||||
if (this.disposed || observing === (this.observing !== null)) return;
|
||||
if (!observing) {
|
||||
this.stopObserving();
|
||||
return;
|
||||
}
|
||||
const generation = ++this.generation;
|
||||
this.observing = { generation, unsubscribe: null };
|
||||
void this.observe(generation);
|
||||
}
|
||||
|
||||
private async observe(generation: number): Promise<void> {
|
||||
let subscription: Awaited<ReturnType<FileSession["observe"]>>;
|
||||
try {
|
||||
subscription = await this.session.observe(this.target, (observation) => {
|
||||
if (this.observing?.generation === generation) this.receive(observation);
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.observing?.generation !== generation) return;
|
||||
this.observing = null;
|
||||
this.setSnapshot({ status: "error", error: errorMessage(error) });
|
||||
return;
|
||||
}
|
||||
if (this.observing?.generation !== generation) {
|
||||
subscription.unsubscribe();
|
||||
return;
|
||||
}
|
||||
this.observing.unsubscribe = subscription.unsubscribe;
|
||||
this.receive(subscription.initial);
|
||||
}
|
||||
|
||||
/** Observes again after a failure. */
|
||||
retry = (): void => {
|
||||
this.stopObserving();
|
||||
this.setObserving(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds this file's writes while the tab asks whether to discard unsaved changes. The returned
|
||||
* function lets them run; closing the tab disposes the handle, which drops them.
|
||||
*/
|
||||
holdWrites = (): (() => void) => {
|
||||
this.holds += 1;
|
||||
if (this.holds === 1) {
|
||||
this.held = new Promise((resolve) => {
|
||||
this.releaseHold = resolve;
|
||||
});
|
||||
}
|
||||
let resumed = false;
|
||||
return () => {
|
||||
if (resumed) return;
|
||||
resumed = true;
|
||||
this.holds -= 1;
|
||||
if (this.holds > 0) return;
|
||||
this.releaseHold?.();
|
||||
this.releaseHold = null;
|
||||
this.held = null;
|
||||
};
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.stopObserving();
|
||||
this.currentUrl?.release();
|
||||
this.currentUrl = null;
|
||||
this.releaseHold?.();
|
||||
this.listeners.clear();
|
||||
this.watchers.clear();
|
||||
}
|
||||
|
||||
private stopObserving(): void {
|
||||
this.observing?.unsubscribe?.();
|
||||
this.observing = null;
|
||||
}
|
||||
|
||||
private receive(observation: FileObservation): void {
|
||||
if (this.disposed) return;
|
||||
this.latest = observation;
|
||||
if (observation.status === "ready") {
|
||||
this.stat = observation;
|
||||
if (!this.base) this.adopt(observation);
|
||||
this.setSnapshot(observation);
|
||||
} else if (!this.stat) {
|
||||
this.setSnapshot(
|
||||
observation.status === "missing"
|
||||
? { status: "missing" }
|
||||
: { status: "error", error: observation.error },
|
||||
);
|
||||
}
|
||||
this.notifyChange(observation);
|
||||
}
|
||||
|
||||
/** Tells watchers about a disk version this handle did not read or write itself. */
|
||||
private notifyChange(observation: FileObservation): void {
|
||||
if (this.writing > 0 || observation.status === "error") return;
|
||||
if (observation.status === "missing") {
|
||||
if (!this.deleted) void this.confirmDeletion();
|
||||
return;
|
||||
}
|
||||
this.notifyVersion(observation);
|
||||
}
|
||||
|
||||
private notifyVersion(version: Version): void {
|
||||
const key = versionKey(version);
|
||||
const restored = this.deleted;
|
||||
this.deleted = false;
|
||||
if (this.seen.has(key) && !restored) return;
|
||||
this.seen.add(key);
|
||||
this.emit({ type: "changed" });
|
||||
}
|
||||
|
||||
private async confirmDeletion(): Promise<void> {
|
||||
if (this.confirmingDeletion) return;
|
||||
this.confirmingDeletion = true;
|
||||
let contents: FileContents | null = null;
|
||||
try {
|
||||
contents = await this.session.read(this.target);
|
||||
} catch {
|
||||
// The read failing is the confirmation.
|
||||
} finally {
|
||||
this.confirmingDeletion = false;
|
||||
}
|
||||
if (this.disposed || this.writing > 0 || this.deleted) return;
|
||||
if (contents) {
|
||||
this.notifyVersion(contents);
|
||||
return;
|
||||
}
|
||||
if (this.latest?.status !== "missing") return;
|
||||
this.deleted = true;
|
||||
this.emit({ type: "deleted" });
|
||||
}
|
||||
|
||||
private emit(event: PluginFileEvent): void {
|
||||
for (const watcher of this.watchers) watcher(event);
|
||||
}
|
||||
|
||||
private async readContents(): Promise<FileContents> {
|
||||
this.assertOpen();
|
||||
await this.writes;
|
||||
const contents = await this.session.read(this.target);
|
||||
this.assertOpen();
|
||||
this.adopt(contents);
|
||||
return contents;
|
||||
}
|
||||
|
||||
private async readText(): Promise<string> {
|
||||
const { bytes } = await this.readContents();
|
||||
this.hasBom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
|
||||
return decodeUtf8(bytes);
|
||||
}
|
||||
|
||||
private async readBytes(): Promise<Uint8Array> {
|
||||
return (await this.readContents()).bytes;
|
||||
}
|
||||
|
||||
private url(): Promise<string> {
|
||||
const current = this.currentUrl;
|
||||
if (current && this.stat && current.key === versionKey(this.stat)) return current.url;
|
||||
return this.createUrl();
|
||||
}
|
||||
|
||||
private async createUrl(): Promise<string> {
|
||||
const contents = await this.readContents();
|
||||
const key = versionKey(contents);
|
||||
if (this.currentUrl?.key === key) return this.currentUrl.url;
|
||||
const created = await this.session.createUrl({
|
||||
target: this.target,
|
||||
bytes: contents.bytes,
|
||||
mimeType: this.file.mimeType,
|
||||
});
|
||||
if (this.disposed) {
|
||||
created.release();
|
||||
throw new Error("The file tab was closed.");
|
||||
}
|
||||
this.currentUrl?.release();
|
||||
this.currentUrl = { key, url: Promise.resolve(created.url), release: created.release };
|
||||
return created.url;
|
||||
}
|
||||
|
||||
private writeText(text: string, options?: PluginFileWriteOptions): Promise<void> {
|
||||
return this.write(this.hasBom ? `${text}` : text, options);
|
||||
}
|
||||
|
||||
private write(content: string | Uint8Array, options?: PluginFileWriteOptions): Promise<void> {
|
||||
const write = this.writes.then(() => this.performWrite(content, options?.force === true));
|
||||
this.writes = write.catch(() => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
private async performWrite(content: string | Uint8Array, force: boolean): Promise<void> {
|
||||
this.assertOpen();
|
||||
if (this.held) await this.held;
|
||||
this.assertOpen();
|
||||
this.writing += 1;
|
||||
try {
|
||||
let expected = force ? (this.stat ?? this.base) : this.base;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
if (!expected) throw new PluginFileConflictError();
|
||||
const result = await this.session.write({
|
||||
target: this.target,
|
||||
content,
|
||||
expectedModifiedAt: expected.modifiedAt,
|
||||
expectedRevision: expected.revision,
|
||||
});
|
||||
if (result.status === "written") {
|
||||
this.adopt(result);
|
||||
return;
|
||||
}
|
||||
if (result.status === "error") throw new Error(result.error);
|
||||
const disk = result.version;
|
||||
if (force && attempt === 0 && disk.status === "ready") {
|
||||
expected = disk;
|
||||
continue;
|
||||
}
|
||||
if (disk.status === "missing") throw new PluginFileDeletedError();
|
||||
throw new PluginFileConflictError();
|
||||
}
|
||||
} finally {
|
||||
this.writing -= 1;
|
||||
// Observations that arrived during the write may be its own version, so they wait for it.
|
||||
if (this.latest && !this.disposed) this.notifyChange(this.latest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a version this handle read or wrote, so its observation does not notify. */
|
||||
private adopt(version: Version): void {
|
||||
this.base = { size: version.size, modifiedAt: version.modifiedAt, revision: version.revision };
|
||||
this.seen.add(versionKey(version));
|
||||
if (this.stat && versionKey(this.stat) !== versionKey(version)) {
|
||||
this.stat = { ...this.stat, ...this.base };
|
||||
this.setSnapshot(this.stat);
|
||||
}
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.disposed) throw new Error("The file tab was closed.");
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: FileHandleSnapshot): void {
|
||||
if (sameSnapshot(this.snapshot, snapshot)) return;
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function versionKey(version: Version): string {
|
||||
return version.revision ?? `${version.size}:${version.modifiedAt}`;
|
||||
}
|
||||
|
||||
function sameSnapshot(current: FileHandleSnapshot, next: FileHandleSnapshot): boolean {
|
||||
if (current.status !== next.status) return false;
|
||||
if (current.status === "ready" && next.status === "ready") {
|
||||
return (
|
||||
versionKey(current) === versionKey(next) &&
|
||||
current.kind === next.kind &&
|
||||
current.mimeType === next.mimeType
|
||||
);
|
||||
}
|
||||
if (current.status === "error" && next.status === "error") return current.error === next.error;
|
||||
return true;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createDaemonFileSession } from "./daemon-session";
|
||||
import { FileHandle, type FileHandleSnapshot, type FileTarget } from "./handle";
|
||||
|
||||
/** The tab target's file handle, observing while `observing`. Disposed when the target changes. */
|
||||
export function useFileHandle(input: {
|
||||
serverId: string;
|
||||
target: FileTarget | null;
|
||||
absolutePath: string;
|
||||
observing: boolean;
|
||||
}): FileHandle | null {
|
||||
const { serverId, target, absolutePath, observing } = input;
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
const workspaceFileEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
|
||||
const fileByteWrites = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fileByteWrites === true,
|
||||
);
|
||||
const cwd = target?.cwd ?? null;
|
||||
const path = target?.path ?? null;
|
||||
const [handle, setHandle] = useState<{ key: string; handle: FileHandle } | null>(null);
|
||||
const key = client && cwd && path ? `${cwd}:${path}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !cwd || !path) {
|
||||
setHandle(null);
|
||||
return;
|
||||
}
|
||||
const session = createDaemonFileSession(client, { workspaceFileEditing, fileByteWrites });
|
||||
const next = new FileHandle(session, { cwd, path }, absolutePath);
|
||||
setHandle({ key: `${cwd}:${path}`, handle: next });
|
||||
return () => next.dispose();
|
||||
}, [absolutePath, client, cwd, fileByteWrites, path, workspaceFileEditing]);
|
||||
|
||||
const current = handle?.key === key ? handle.handle : null;
|
||||
useEffect(() => {
|
||||
current?.setObserving(observing);
|
||||
}, [current, observing]);
|
||||
return current;
|
||||
}
|
||||
|
||||
const LOADING: FileHandleSnapshot = { status: "loading" };
|
||||
const subscribeNothing = () => () => {};
|
||||
const getLoading = () => LOADING;
|
||||
|
||||
export function useFileHandleSnapshot(handle: FileHandle | null): FileHandleSnapshot {
|
||||
return useSyncExternalStore(
|
||||
handle?.subscribe ?? subscribeNothing,
|
||||
handle?.getSnapshot ?? getLoading,
|
||||
handle?.getSnapshot ?? getLoading,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { PluginFileDeletedError, useFileText, useFileUrl } from "@getpaseo/plugin/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileHandle } from "./handle";
|
||||
import { InMemoryFileSession } from "./testing";
|
||||
|
||||
function openFile(content = "first\n") {
|
||||
const session = new InMemoryFileSession();
|
||||
session.put("notes.md", content);
|
||||
const handle = new FileHandle(session, { cwd: "/repo", path: "notes.md" }, "/repo/notes.md");
|
||||
handle.setObserving(true);
|
||||
return { session, handle, file: handle.file };
|
||||
}
|
||||
|
||||
describe("file hooks", () => {
|
||||
it("reads the text, then follows changes without going back to loading", async () => {
|
||||
const { session, file } = openFile();
|
||||
const states: string[] = [];
|
||||
const { result } = renderHook(() => {
|
||||
const text = useFileText(file);
|
||||
states.push(text.status);
|
||||
return text;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "first\n" }));
|
||||
act(() => session.put("notes.md", "second\n"));
|
||||
await waitFor(() => expect(result.current).toEqual({ status: "ready", text: "second\n" }));
|
||||
|
||||
expect(states.lastIndexOf("loading")).toBeLessThan(states.indexOf("ready"));
|
||||
});
|
||||
|
||||
it("reports a deleted file as a PluginFileDeletedError", async () => {
|
||||
const { session, file } = openFile();
|
||||
const { result } = renderHook(() => useFileText(file));
|
||||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||||
|
||||
act(() => session.remove("notes.md"));
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||
expect(result.current.status === "error" && result.current.error).toBeInstanceOf(
|
||||
PluginFileDeletedError,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a URL per version and releases the old one", async () => {
|
||||
const { session, file } = openFile();
|
||||
const { result } = renderHook(() => useFileUrl(file));
|
||||
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/1" }));
|
||||
|
||||
act(() => session.put("notes.md", "second\n"));
|
||||
|
||||
await waitFor(() => expect(result.current).toEqual({ status: "ready", url: "blob:test/2" }));
|
||||
expect(session.releasedUrls).toEqual(["blob:test/1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileHandle, type FileHandleSnapshot, type FileStat, type FileTarget } from "./handle";
|
||||
export { useFileHandle, useFileHandleSnapshot } from "./hook";
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { FileWriteResult } from "@getpaseo/protocol/messages";
|
||||
import type { FileContents, FileObservation, FileSession, FileTarget } from "./handle";
|
||||
|
||||
interface StoredFile {
|
||||
bytes: Uint8Array;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file session over an in-memory disk, for tests. A write delivers its own observation before
|
||||
* it resolves, the order the daemon's watcher can produce.
|
||||
*/
|
||||
export class InMemoryFileSession implements FileSession {
|
||||
readonly createdUrls: string[] = [];
|
||||
readonly releasedUrls: string[] = [];
|
||||
readonly writes: string[] = [];
|
||||
private readonly files = new Map<string, StoredFile>();
|
||||
private readonly observers = new Set<{
|
||||
path: string;
|
||||
onObservation: (observation: FileObservation) => void;
|
||||
}>();
|
||||
private revision = 0;
|
||||
private urlCount = 0;
|
||||
private nextWriteError: string | null = null;
|
||||
|
||||
/** The next write reports this error instead of writing. */
|
||||
failNextWrite(error: string): void {
|
||||
this.nextWriteError = error;
|
||||
}
|
||||
|
||||
/** Writes the file as another program would. */
|
||||
put(path: string, content: string | Uint8Array): void {
|
||||
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
||||
this.files.set(path, { bytes, revision: ++this.revision });
|
||||
this.notify(path);
|
||||
}
|
||||
|
||||
remove(path: string): void {
|
||||
this.files.delete(path);
|
||||
this.notify(path);
|
||||
}
|
||||
|
||||
text(path: string): string | null {
|
||||
const file = this.files.get(path);
|
||||
return file ? new TextDecoder("utf-8", { ignoreBOM: true }).decode(file.bytes) : null;
|
||||
}
|
||||
|
||||
observerCount(): number {
|
||||
return this.observers.size;
|
||||
}
|
||||
|
||||
async observe(target: FileTarget, onObservation: (observation: FileObservation) => void) {
|
||||
const observer = { path: target.path, onObservation };
|
||||
this.observers.add(observer);
|
||||
return {
|
||||
initial: this.observation(target.path),
|
||||
unsubscribe: () => {
|
||||
this.observers.delete(observer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async read(target: FileTarget): Promise<FileContents> {
|
||||
const file = this.files.get(target.path);
|
||||
if (!file) throw new Error("File not found");
|
||||
return { bytes: file.bytes, ...version(file) };
|
||||
}
|
||||
|
||||
async write(input: {
|
||||
target: FileTarget;
|
||||
content: string | Uint8Array;
|
||||
expectedModifiedAt: string;
|
||||
expectedRevision?: string;
|
||||
}): Promise<FileWriteResult> {
|
||||
if (this.nextWriteError) {
|
||||
const error = this.nextWriteError;
|
||||
this.nextWriteError = null;
|
||||
return { status: "error", error };
|
||||
}
|
||||
const file = this.files.get(input.target.path);
|
||||
if (!file) {
|
||||
return {
|
||||
status: "conflict",
|
||||
version: { status: "missing", cwd: "/", path: input.target.path },
|
||||
};
|
||||
}
|
||||
if (input.expectedRevision !== String(file.revision)) {
|
||||
return {
|
||||
status: "conflict",
|
||||
version: { status: "ready", cwd: "/", path: input.target.path, ...version(file) },
|
||||
};
|
||||
}
|
||||
this.writes.push(
|
||||
typeof input.content === "string" ? input.content : `bytes:${input.content.length}`,
|
||||
);
|
||||
this.put(input.target.path, input.content);
|
||||
const written = this.files.get(input.target.path)!;
|
||||
return { status: "written", ...version(written) };
|
||||
}
|
||||
|
||||
async createUrl() {
|
||||
const url = `blob:test/${++this.urlCount}`;
|
||||
this.createdUrls.push(url);
|
||||
return { url, release: () => this.releasedUrls.push(url) };
|
||||
}
|
||||
|
||||
private observation(path: string): FileObservation {
|
||||
const file = this.files.get(path);
|
||||
if (!file) return { status: "missing", cwd: "/", path };
|
||||
return { status: "ready", kind: "text", mimeType: "text/plain", ...version(file) };
|
||||
}
|
||||
|
||||
private notify(path: string): void {
|
||||
const observation = this.observation(path);
|
||||
for (const observer of this.observers) {
|
||||
if (observer.path === path) observer.onObservation(observation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function version(file: StoredFile) {
|
||||
return {
|
||||
size: file.bytes.length,
|
||||
modifiedAt: new Date(file.revision * 1000).toISOString(),
|
||||
revision: String(file.revision),
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function FileHtmlPreview({ html, testID }: { html: string; testID?: strin
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.editor.preview")}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
srcDoc={document}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import type { ExplorerFile } from "@/stores/session-store";
|
||||
import { builtinFilePanels } from "./catalog";
|
||||
import { resolveFilePanel } from "./resolve";
|
||||
|
||||
export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve";
|
||||
export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog";
|
||||
export { FilePanelPreview } from "./builtin";
|
||||
|
||||
export type FilePanel = (typeof builtinFilePanels)[number];
|
||||
|
||||
export function resolveFilePreviewPanel(input: {
|
||||
preview: ExplorerFile | null;
|
||||
path: string;
|
||||
associations: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
const { preview, path, associations } = input;
|
||||
if (!preview) return null;
|
||||
return resolveFilePanel({
|
||||
file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" },
|
||||
candidates: builtinFilePanels,
|
||||
associations,
|
||||
}).panel;
|
||||
}
|
||||
export { FilePanelView } from "./view";
|
||||
export type { FilePanel } from "./panels";
|
||||
export {
|
||||
useFilePanelChoice,
|
||||
useFilePanelChoiceSnapshot,
|
||||
type FilePanelChoice,
|
||||
} from "./choice/hook";
|
||||
export { FilePanelPicker } from "./choice/picker";
|
||||
export { FilePanelSwitcher } from "./choice/switcher";
|
||||
export {
|
||||
FilePanelBarMenu,
|
||||
useFilePanelOpenWithMenu,
|
||||
type FilePanelOpenWithMenu,
|
||||
} from "./choice/open-with";
|
||||
export { DocumentFrame } from "./document-frame";
|
||||
export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation";
|
||||
export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook";
|
||||
export type { FilePanelSettingsRow } from "./settings/rows";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** The file tab's loading state. It covers the whole panel, so a panel can render beneath it. */
|
||||
export function FilePanelLoading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.overlay} testID="file-preview-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.text}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
text: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
PluginFile,
|
||||
PluginFileLocation,
|
||||
PluginFilePanelContribution,
|
||||
PluginFileTab,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
|
||||
|
||||
export interface PluginFilePanel {
|
||||
id: string;
|
||||
builtin: false;
|
||||
files: PluginFilePanelContribution["files"];
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginFilePanelContribution;
|
||||
}
|
||||
export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel;
|
||||
|
||||
/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */
|
||||
export interface BuiltinFilePanelProps {
|
||||
file: PluginFile;
|
||||
tab: PluginFileTab;
|
||||
location: PluginFileLocation;
|
||||
target: { serverId: string; workspaceRoot: string; path: string };
|
||||
}
|
||||
|
||||
export function useFilePanels(serverId: string): readonly FilePanel[] {
|
||||
const plugins = useInstalledPlugins();
|
||||
return useMemo(
|
||||
() => hostFilePanels(plugins.filter((plugin) => plugin.serverId === serverId)),
|
||||
[plugins, serverId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Every host's panels; a plugin installed on several hosts contributes its panels once per host. */
|
||||
export function useAllHostsFilePanels(): readonly FilePanel[] {
|
||||
const plugins = useInstalledPlugins();
|
||||
return useMemo(() => hostFilePanels(plugins), [plugins]);
|
||||
}
|
||||
|
||||
function hostFilePanels(plugins: readonly InstalledPlugin[]): readonly FilePanel[] {
|
||||
return filePanelCatalog<PluginFilePanel>(
|
||||
plugins.flatMap((plugin) =>
|
||||
plugin.filePanels.map((contribution) => ({
|
||||
id: `plugin:${plugin.id}/${contribution.id}`,
|
||||
builtin: false,
|
||||
files: contribution.files,
|
||||
plugin,
|
||||
contribution,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { createPluginClientStateSource } from "@/plugins/client-state/source";
|
||||
import { usePluginHostNavigation } from "@/plugins/host-navigation";
|
||||
import { PluginInstallationProvider } from "@/plugins/installation-provider";
|
||||
import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary";
|
||||
import { resolvePluginPlatform } from "@/plugins/platform";
|
||||
import { toPluginTheme } from "@/plugins/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels";
|
||||
|
||||
type Props = Pick<BuiltinFilePanelProps, "file" | "tab" | "location"> & { panel: PluginFilePanel };
|
||||
|
||||
function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) {
|
||||
const { workspaceId } = usePaneContext();
|
||||
const { plugin, contribution } = panel;
|
||||
const serverId = plugin.serverId;
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const navigation = usePluginHostNavigation(serverId);
|
||||
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]);
|
||||
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
|
||||
const Component = contribution.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component} key={panel.id}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<PluginClientStateProvider source={stateSource}>
|
||||
<Component
|
||||
theme={theme}
|
||||
host={host}
|
||||
layout={layout}
|
||||
navigation={navigation}
|
||||
workspaceId={workspaceId}
|
||||
file={file}
|
||||
tab={tab}
|
||||
location={location}
|
||||
/>
|
||||
</PluginClientStateProvider>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
const ThemedHost = withUnistyles(PluginHost);
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginFilePanelHost(props: Props) {
|
||||
return <ThemedHost {...props} uniProps={themeMapping} />;
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from ".
|
||||
|
||||
const markdown: FilePanelCandidate = {
|
||||
id: "builtin:markdown",
|
||||
builtin: true,
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
document: "text",
|
||||
maxBytes: Infinity,
|
||||
fileKind: "text",
|
||||
};
|
||||
const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" };
|
||||
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true };
|
||||
const alternative: FilePanelCandidate = {
|
||||
id: "plugin:notes/markdown",
|
||||
builtin: false,
|
||||
files: { extensions: ["md", "markdown"] },
|
||||
};
|
||||
const file: FilePanelFile = { path: "/docs/README.MD", kind: "text" };
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveFilePanel<FilePanelCandidate>>[0]> = {},
|
||||
@@ -27,6 +31,7 @@ describe("resolveFilePanel", () => {
|
||||
panel: alternative,
|
||||
candidates: [markdown, alternative],
|
||||
selectorKey: "ext:md",
|
||||
associatedPanelId: alternative.id,
|
||||
});
|
||||
});
|
||||
it("ignores an absent association without deleting it", () => {
|
||||
@@ -39,33 +44,43 @@ describe("resolveFilePanel", () => {
|
||||
expect(resolve().panel).toBe(markdown);
|
||||
expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative);
|
||||
});
|
||||
it("uses a tab choice before the association and ignores a choice that is not a candidate", () => {
|
||||
const associations = { "ext:md": alternative.id };
|
||||
expect(resolve({ associations, choice: markdown.id })).toMatchObject({
|
||||
panel: markdown,
|
||||
associatedPanelId: alternative.id,
|
||||
});
|
||||
expect(resolve({ associations, choice: "plugin:absent/markdown" }).panel).toBe(alternative);
|
||||
});
|
||||
it("returns the floor when no selector matches", () => {
|
||||
expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({
|
||||
panel: null,
|
||||
candidates: [],
|
||||
selectorKey: "ext:ts",
|
||||
associatedPanelId: null,
|
||||
});
|
||||
});
|
||||
it("filters text panels for binary files even with an association", () => {
|
||||
it("offers a panel claiming every file of its kind for files of that kind", () => {
|
||||
const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" };
|
||||
expect(resolve({ file: { ...file, path: "index.ts" }, candidates: [every] })).toEqual({
|
||||
panel: every,
|
||||
candidates: [every],
|
||||
selectorKey: "ext:ts",
|
||||
associatedPanelId: null,
|
||||
});
|
||||
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull();
|
||||
});
|
||||
it("passes over a built-in panel for another kind of file, even with an association", () => {
|
||||
expect(
|
||||
resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel,
|
||||
).toBeNull();
|
||||
).toBe(alternative);
|
||||
});
|
||||
it("requires bytes for bytes panels and accepts binary files with bytes", () => {
|
||||
const bytes: FilePanelCandidate = { ...alternative, document: "bytes" };
|
||||
expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull();
|
||||
expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes);
|
||||
});
|
||||
it("includes the maxBytes boundary and skips oversized associated panels", () => {
|
||||
const limited = { ...alternative, maxBytes: 100 };
|
||||
expect(resolve({ candidates: [limited] }).panel).toBe(limited);
|
||||
expect(
|
||||
resolve({
|
||||
file: { ...file, size: 101 },
|
||||
candidates: [limited, markdown],
|
||||
associations: { "ext:md": limited.id },
|
||||
}).panel,
|
||||
).toBe(markdown);
|
||||
it("offers plugin panels for every kind of file their selector matches", () => {
|
||||
for (const kind of ["text", "image", "binary"] as const) {
|
||||
expect(resolve({ file: { ...file, kind }, candidates: [alternative] }).panel).toBe(
|
||||
alternative,
|
||||
);
|
||||
}
|
||||
});
|
||||
it("uses exact basename keys when an eligible candidate matches by filename", () => {
|
||||
const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } };
|
||||
@@ -75,16 +90,19 @@ describe("resolveFilePanel", () => {
|
||||
panel: named,
|
||||
candidates: [markdown, named],
|
||||
selectorKey: "name:README.MD",
|
||||
associatedPanelId: named.id,
|
||||
});
|
||||
expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull();
|
||||
});
|
||||
it("uses extension keys when filename candidates were filtered out", () => {
|
||||
const named = {
|
||||
...alternative,
|
||||
maxBytes: 1,
|
||||
...markdown,
|
||||
id: "builtin:named",
|
||||
files: { extensions: [], filenames: ["README.MD"] },
|
||||
};
|
||||
expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md");
|
||||
expect(
|
||||
resolve({ file: { ...file, kind: "binary" }, candidates: [named, alternative] }).selectorKey,
|
||||
).toBe("ext:md");
|
||||
});
|
||||
it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])(
|
||||
"matches an extensionless exact filename: %s",
|
||||
@@ -94,6 +112,7 @@ describe("resolveFilePanel", () => {
|
||||
panel: named,
|
||||
candidates: [named],
|
||||
selectorKey: "name:Dockerfile",
|
||||
associatedPanelId: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -103,3 +122,15 @@ describe("resolveFilePanel", () => {
|
||||
expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a binary PDF with its plugin by its selector alone", () => {
|
||||
const pdf: FilePanelCandidate = {
|
||||
id: "plugin:pdf/viewer",
|
||||
builtin: false,
|
||||
files: { extensions: ["pdf"] },
|
||||
};
|
||||
const pdfFile: FilePanelFile = { path: "/workspace/document.pdf", kind: "binary" };
|
||||
expect(
|
||||
resolveFilePanel({ file: pdfFile, candidates: [markdown, pdf], associations: {} }).panel,
|
||||
).toBe(pdf);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type FilePanelFileKind = "text" | "image" | "binary";
|
||||
|
||||
/** What resolution knows about a file: its path and the kind its stat reports, never contents. */
|
||||
export interface FilePanelFile {
|
||||
path: string;
|
||||
kind: "text" | "image" | "binary";
|
||||
size: number;
|
||||
hasBytes: boolean;
|
||||
kind: FilePanelFileKind;
|
||||
}
|
||||
|
||||
export interface FilePanelSelector {
|
||||
@@ -10,17 +11,24 @@ export interface FilePanelSelector {
|
||||
filenames?: readonly string[];
|
||||
}
|
||||
|
||||
/** A selector, or `"all"`: every file of the panel's `fileKind`. */
|
||||
export type FilePanelFiles = FilePanelSelector | "all";
|
||||
|
||||
export interface FilePanelCandidate {
|
||||
id: string;
|
||||
files: FilePanelSelector;
|
||||
document: "text" | "bytes";
|
||||
maxBytes: number;
|
||||
/** Paseo's own panel. Paseo asks which panel opens a file only when a plugin panel claims it. */
|
||||
builtin: boolean;
|
||||
files: FilePanelFiles;
|
||||
/** The only kind of file a built-in panel reads. Plugin panels are offered for any kind. */
|
||||
fileKind?: FilePanelFileKind;
|
||||
}
|
||||
|
||||
export interface FilePanelResolution<Candidate> {
|
||||
panel: Candidate | null;
|
||||
candidates: readonly Candidate[];
|
||||
selectorKey: string;
|
||||
/** The remembered candidate for `selectorKey`, when this host has it. */
|
||||
associatedPanelId: string | null;
|
||||
}
|
||||
|
||||
export function filePanelExtension(path: string): string {
|
||||
@@ -33,31 +41,89 @@ function filePanelBasename(path: string): string {
|
||||
return path.trim().split(/[\\/]/).pop() ?? "";
|
||||
}
|
||||
|
||||
function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean {
|
||||
if (selector.filenames?.includes(filePanelBasename(file.path))) return true;
|
||||
const extension = filePanelExtension(file.path);
|
||||
/** Which files an association applies to: one extension, or one exact filename. */
|
||||
export interface FilePanelSelectorKey {
|
||||
kind: "ext" | "name";
|
||||
value: string;
|
||||
}
|
||||
|
||||
function filePanelSelectorKey(key: FilePanelSelectorKey): string {
|
||||
return `${key.kind}:${key.value}`;
|
||||
}
|
||||
|
||||
export function parseFilePanelSelectorKey(selectorKey: string): FilePanelSelectorKey {
|
||||
const separator = selectorKey.indexOf(":");
|
||||
const kind = selectorKey.slice(0, separator) === "name" ? "name" : "ext";
|
||||
return { kind, value: selectorKey.slice(separator + 1) };
|
||||
}
|
||||
|
||||
function matchesFiles(file: FilePanelFile, files: FilePanelFiles): boolean {
|
||||
if (files === "all") return true;
|
||||
return matchesName(filePanelBasename(file.path), filePanelExtension(file.path), files);
|
||||
}
|
||||
|
||||
function claimsFilename(files: FilePanelFiles, filename: string): boolean {
|
||||
return files !== "all" && Boolean(files.filenames?.includes(filename));
|
||||
}
|
||||
|
||||
function matchesName(name: string, extension: string, selector: FilePanelSelector): boolean {
|
||||
if (selector.filenames?.includes(name)) return true;
|
||||
return selector.extensions.some((entry) => entry.toLowerCase() === extension);
|
||||
}
|
||||
|
||||
/** The selector keys a panel claims by name: one per extension and one per filename. */
|
||||
export function filePanelClaimedSelectorKeys(files: FilePanelFiles): string[] {
|
||||
if (files === "all") return [];
|
||||
const selector = files;
|
||||
return [
|
||||
...selector.extensions.map((extension) =>
|
||||
filePanelSelectorKey({ kind: "ext", value: extension.toLowerCase() }),
|
||||
),
|
||||
...(selector.filenames ?? []).map((filename) =>
|
||||
filePanelSelectorKey({ kind: "name", value: filename }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a panel is offered for files under `selectorKey`, by the resolver's matching rule. The
|
||||
* file kind depends on the file, so it is not part of the answer.
|
||||
*/
|
||||
export function claimsFilePanelSelectorKey(
|
||||
selector: FilePanelSelector,
|
||||
selectorKey: string,
|
||||
): boolean {
|
||||
const { kind, value } = parseFilePanelSelectorKey(selectorKey);
|
||||
return kind === "name"
|
||||
? matchesName(value, filePanelExtension(value), selector)
|
||||
: matchesName("", value, selector);
|
||||
}
|
||||
|
||||
/** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */
|
||||
export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean {
|
||||
return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin);
|
||||
}
|
||||
|
||||
export function resolveFilePanel<Candidate extends FilePanelCandidate>(input: {
|
||||
file: FilePanelFile;
|
||||
candidates: readonly Candidate[];
|
||||
associations: Readonly<Record<string, string>>;
|
||||
/** A panel chosen for this tab open; wins over the association while it is a candidate. */
|
||||
choice?: string | null;
|
||||
}): FilePanelResolution<Candidate> {
|
||||
const { file, associations } = input;
|
||||
const candidates = input.candidates.filter((candidate) => {
|
||||
const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes;
|
||||
return (
|
||||
documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files)
|
||||
);
|
||||
});
|
||||
const filename = filePanelBasename(file.path);
|
||||
const matchedFilename = candidates.some((candidate) =>
|
||||
candidate.files.filenames?.includes(filename),
|
||||
const candidates = input.candidates.filter(
|
||||
(candidate) =>
|
||||
(candidate.fileKind === undefined || candidate.fileKind === file.kind) &&
|
||||
matchesFiles(file, candidate.files),
|
||||
);
|
||||
const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`;
|
||||
const association = associations[selectorKey];
|
||||
const panel =
|
||||
candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null;
|
||||
return { panel, candidates, selectorKey };
|
||||
const filename = filePanelBasename(file.path);
|
||||
const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename));
|
||||
const selectorKey = matchedFilename
|
||||
? filePanelSelectorKey({ kind: "name", value: filename })
|
||||
: filePanelSelectorKey({ kind: "ext", value: filePanelExtension(file.path) });
|
||||
const associated = candidates.find((candidate) => candidate.id === associations[selectorKey]);
|
||||
const chosen = candidates.find((candidate) => candidate.id === input.choice);
|
||||
const panel = chosen ?? associated ?? candidates[0] ?? null;
|
||||
return { panel, candidates, selectorKey, associatedPanelId: associated?.id ?? null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useFilePanelAssociations } from "../associations";
|
||||
import { useAllHostsFilePanels, type FilePanel } from "../panels";
|
||||
import { buildFilePanelSettingsRows, type FilePanelSettingsRow } from "./rows";
|
||||
|
||||
export interface FilePanelSettings {
|
||||
rows: readonly FilePanelSettingsRow<FilePanel>[];
|
||||
/** Remembers `panelId` for the key; the next open of a matching file uses it. */
|
||||
choose(selectorKey: string, panelId: string): void;
|
||||
/** Forgets the key's panel, so the next open of a matching file asks. */
|
||||
askAgain(selectorKey: string): void;
|
||||
}
|
||||
|
||||
/** Settings > Files: the keys worth configuring across every host, and the commands on them. */
|
||||
export function useFilePanelSettings(): FilePanelSettings {
|
||||
const candidates = useAllHostsFilePanels();
|
||||
const { associations, remember } = useFilePanelAssociations();
|
||||
const rows = useMemo(
|
||||
() => buildFilePanelSettingsRows({ candidates, associations }),
|
||||
[associations, candidates],
|
||||
);
|
||||
const askAgain = useCallback((selectorKey: string) => remember(selectorKey, null), [remember]);
|
||||
return { rows, choose: remember, askAgain };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filePanelCatalog, sourceFilePanel } from "../catalog";
|
||||
import type { FilePanelCandidate } from "../resolve";
|
||||
import { buildFilePanelSettingsRows } from "./rows";
|
||||
|
||||
type HostedCandidate = FilePanelCandidate & { serverId?: string };
|
||||
|
||||
const builtinMarkdown: HostedCandidate = filePanelCatalog([])[0];
|
||||
const builtinImage: HostedCandidate = filePanelCatalog([])[2];
|
||||
const source: HostedCandidate = sourceFilePanel;
|
||||
|
||||
/** What Settings reads: the built-ins and every host's plugin panels, in catalog order. */
|
||||
function catalog(...plugins: HostedCandidate[]): HostedCandidate[] {
|
||||
return [...filePanelCatalog(plugins)];
|
||||
}
|
||||
|
||||
function richMarkdown(serverId: string): HostedCandidate {
|
||||
return {
|
||||
id: "plugin:markdown-panel/rich-markdown",
|
||||
builtin: false,
|
||||
files: { extensions: ["MD"] },
|
||||
serverId,
|
||||
};
|
||||
}
|
||||
|
||||
const dockerfile: HostedCandidate = {
|
||||
id: "plugin:docker/dockerfile",
|
||||
builtin: false,
|
||||
files: { extensions: [], filenames: ["Dockerfile"] },
|
||||
serverId: "host-a",
|
||||
};
|
||||
|
||||
function rows(candidates: HostedCandidate[], associations: Record<string, string> = {}) {
|
||||
return buildFilePanelSettingsRows({ candidates, associations });
|
||||
}
|
||||
|
||||
describe("buildFilePanelSettingsRows", () => {
|
||||
it("has nothing to configure when only built-in panels claim a key", () => {
|
||||
expect(rows(catalog())).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists a key a plugin panel claims, built-in first and Source last", () => {
|
||||
const plugin = richMarkdown("host-a");
|
||||
expect(rows(catalog(plugin))).toEqual([
|
||||
{
|
||||
selectorKey: "ext:md",
|
||||
candidates: [builtinMarkdown, plugin, source],
|
||||
associatedPanelId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Source on a key only a plugin panel claims, as the picker does for a text file", () => {
|
||||
const toml: HostedCandidate = {
|
||||
id: "plugin:toml/toml",
|
||||
builtin: false,
|
||||
files: { extensions: ["toml"] },
|
||||
};
|
||||
expect(rows(catalog(toml))).toEqual([
|
||||
{ selectorKey: "ext:toml", candidates: [toml, source], associatedPanelId: null },
|
||||
]);
|
||||
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
|
||||
expect(rows(catalog(dockerfile, other))[0]?.candidates).toEqual([dockerfile, other, source]);
|
||||
});
|
||||
|
||||
it("does not offer Source on a key the built-in image panel claims", () => {
|
||||
const viewer: HostedCandidate = {
|
||||
id: "plugin:viewer/png",
|
||||
builtin: false,
|
||||
files: { extensions: ["png"] },
|
||||
};
|
||||
expect(rows(catalog(viewer))).toEqual([
|
||||
{ selectorKey: "ext:png", candidates: [builtinImage, viewer], associatedPanelId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a panel installed on several hosts once, as the first host has it", () => {
|
||||
const first = richMarkdown("host-a");
|
||||
const [row] = rows(catalog(first, richMarkdown("host-b")));
|
||||
expect(row?.candidates).toEqual([builtinMarkdown, first, source]);
|
||||
});
|
||||
|
||||
it("keeps a remembered key that only built-in panels claim", () => {
|
||||
expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([
|
||||
{
|
||||
selectorKey: "ext:markdown",
|
||||
candidates: [builtinMarkdown, source],
|
||||
associatedPanelId: source.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a remembered key whose panel is on no connected host", () => {
|
||||
expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([
|
||||
{ selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers extension panels on a filename key the way the resolver does", () => {
|
||||
const named: HostedCandidate = {
|
||||
...dockerfile,
|
||||
id: "plugin:readme/readme",
|
||||
files: { extensions: [], filenames: ["README.md"] },
|
||||
};
|
||||
const keys = rows(catalog(named)).map((row) => row.selectorKey);
|
||||
expect(keys).toEqual(["name:README.md"]);
|
||||
expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]);
|
||||
});
|
||||
|
||||
it("orders extensions before filenames, each alphabetically", () => {
|
||||
const other: HostedCandidate = { ...dockerfile, id: "plugin:other/dockerfile" };
|
||||
const html: HostedCandidate = {
|
||||
...richMarkdown("host-a"),
|
||||
id: "plugin:web/html",
|
||||
files: { extensions: ["html"] },
|
||||
};
|
||||
const keys = rows(catalog(dockerfile, other, html, richMarkdown("host-a"))).map(
|
||||
(row) => row.selectorKey,
|
||||
);
|
||||
expect(keys).toEqual(["ext:html", "ext:md", "name:Dockerfile"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
asksForFilePanel,
|
||||
claimsFilePanelSelectorKey,
|
||||
filePanelClaimedSelectorKeys,
|
||||
parseFilePanelSelectorKey,
|
||||
type FilePanelCandidate,
|
||||
} from "../resolve";
|
||||
|
||||
/** One selector key in Settings > Files. */
|
||||
export interface FilePanelSettingsRow<Candidate> {
|
||||
selectorKey: string;
|
||||
/** Every known panel for the key, coalesced across hosts, in resolver order. */
|
||||
candidates: readonly Candidate[];
|
||||
/** The remembered panel, which may be on no connected host; null means Paseo asks on open. */
|
||||
associatedPanelId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys worth configuring: every key where Paseo would ask, and every key that has an
|
||||
* association, so a remembered choice can always be changed or forgotten. A panel installed on
|
||||
* several hosts is one candidate.
|
||||
*/
|
||||
export function buildFilePanelSettingsRows<Candidate extends FilePanelCandidate>(input: {
|
||||
candidates: readonly Candidate[];
|
||||
associations: Readonly<Record<string, string>>;
|
||||
}): FilePanelSettingsRow<Candidate>[] {
|
||||
const candidates = coalesceById(input.candidates);
|
||||
const keys = new Set([
|
||||
...candidates.flatMap((candidate) => filePanelClaimedSelectorKeys(candidate.files)),
|
||||
...Object.keys(input.associations),
|
||||
]);
|
||||
return [...keys]
|
||||
.sort(compareSelectorKeys)
|
||||
.map((selectorKey) => ({
|
||||
selectorKey,
|
||||
candidates: offeredCandidates(candidates, selectorKey),
|
||||
associatedPanelId: input.associations[selectorKey] ?? null,
|
||||
}))
|
||||
.filter((row) => asksForFilePanel(row.candidates) || row.associatedPanelId !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The panels offered under a key, by the resolver's matching rule. A panel claiming every file of
|
||||
* a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a
|
||||
* file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file
|
||||
* when it opens is ignored there.
|
||||
*/
|
||||
function offeredCandidates<Candidate extends FilePanelCandidate>(
|
||||
candidates: readonly Candidate[],
|
||||
selectorKey: string,
|
||||
): Candidate[] {
|
||||
const claiming = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.files !== "all" && claimsFilePanelSelectorKey(candidate.files, selectorKey),
|
||||
);
|
||||
return candidates.filter((candidate) =>
|
||||
candidate.files === "all"
|
||||
? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind)
|
||||
: claiming.includes(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
function coalesceById<Candidate extends { id: string }>(
|
||||
candidates: readonly Candidate[],
|
||||
): Candidate[] {
|
||||
const byId = new Map<string, Candidate>();
|
||||
for (const candidate of candidates) {
|
||||
if (!byId.has(candidate.id)) byId.set(candidate.id, candidate);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Extensions before filenames, each alphabetically. */
|
||||
function compareSelectorKeys(left: string, right: string): number {
|
||||
const a = parseFilePanelSelectorKey(left);
|
||||
const b = parseFilePanelSelectorKey(right);
|
||||
if (a.kind !== b.kind) return a.kind === "ext" ? -1 : 1;
|
||||
return a.value.localeCompare(b.value);
|
||||
}
|
||||
+6
-15
@@ -10,14 +10,12 @@ const warningIconMapping = (theme: Theme) => ({ color: theme.colors.palette.ambe
|
||||
|
||||
export type FileConflictAlertState =
|
||||
| { kind: "changed"; canOverwrite: boolean; onReload(): void; onOverwrite(): void }
|
||||
| { kind: "deleted" }
|
||||
| { kind: "checkFailed"; retrying: boolean; onRetry(): void };
|
||||
| { kind: "deleted" };
|
||||
|
||||
export function FileConflictAlert({ state }: { state: FileConflictAlertState }) {
|
||||
const { t } = useTranslation();
|
||||
let title = t("panels.file.editor.changedOnDisk");
|
||||
if (state.kind === "deleted") title = t("panels.file.editor.deletedTitle");
|
||||
else if (state.kind === "checkFailed") title = t("panels.file.editor.checkFailedTitle");
|
||||
let description: string | undefined;
|
||||
if (state.kind !== "changed") description = t("panels.file.editor.preservedDescription");
|
||||
else if (state.canOverwrite) description = t("panels.file.editor.conflictDescription");
|
||||
@@ -29,23 +27,16 @@ export function FileConflictAlert({ state }: { state: FileConflictAlertState })
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
</View>
|
||||
{state.kind !== "deleted" ? (
|
||||
{state.kind === "changed" ? (
|
||||
<View style={styles.actions}>
|
||||
{state.kind === "changed" && state.canOverwrite ? (
|
||||
{state.canOverwrite ? (
|
||||
<Button variant="outline" size="sm" onPress={state.onOverwrite}>
|
||||
{t("panels.file.editor.overwrite")}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.kind === "changed" ? (
|
||||
<Button variant="outline" size="sm" onPress={state.onReload}>
|
||||
{t("panels.file.editor.reload")}
|
||||
</Button>
|
||||
) : null}
|
||||
{state.kind === "checkFailed" ? (
|
||||
<Button variant="outline" size="sm" onPress={state.onRetry} loading={state.retrying}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onPress={state.onReload}>
|
||||
{t("panels.file.editor.reload")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { FileHandle } from "../../file/handle";
|
||||
import { InMemoryFileSession } from "../../file/testing";
|
||||
import { FileEditorModel, getFileConflictCallout, type FileEditorClock } from "./model";
|
||||
|
||||
class TestClock implements FileEditorClock {
|
||||
private callback: (() => void) | null = null;
|
||||
|
||||
setTimeout(callback: () => void): ReturnType<typeof setTimeout> {
|
||||
this.callback = callback;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
clearTimeout(): void {
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
get scheduled(): boolean {
|
||||
return this.callback !== null;
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
const callback = this.callback;
|
||||
this.callback = null;
|
||||
callback?.();
|
||||
}
|
||||
}
|
||||
|
||||
function settle(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function openEditor(content: string | Uint8Array = "first\n") {
|
||||
const session = new InMemoryFileSession();
|
||||
session.put("source.ts", content);
|
||||
const handle = new FileHandle(session, { cwd: "/repo", path: "source.ts" }, "/repo/source.ts");
|
||||
handle.setObserving(true);
|
||||
await settle();
|
||||
const clock = new TestClock();
|
||||
const model = new FileEditorModel({ file: handle.file, clock });
|
||||
model.open();
|
||||
await settle();
|
||||
return { session, handle, clock, model, disk: () => session.text("source.ts") };
|
||||
}
|
||||
|
||||
describe("FileEditorModel", () => {
|
||||
test("loads the file clean", async () => {
|
||||
const { model } = await openEditor();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "clean",
|
||||
content: "first\n",
|
||||
modified: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports a file it cannot read", async () => {
|
||||
const session = new InMemoryFileSession();
|
||||
const handle = new FileHandle(session, { cwd: "/repo", path: "gone.ts" }, "/repo/gone.ts");
|
||||
const model = new FileEditorModel({ file: handle.file });
|
||||
model.open();
|
||||
await settle();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "failed", error: "File not found" });
|
||||
});
|
||||
|
||||
test("tracks whether the buffer differs from the file", async () => {
|
||||
const { model } = await openEditor();
|
||||
|
||||
model.edit("second\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
|
||||
model.edit("first\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
});
|
||||
|
||||
test("autosaves the latest edit after inactivity", async () => {
|
||||
const { model, clock, disk } = await openEditor();
|
||||
|
||||
model.edit("draft\n");
|
||||
model.edit("latest\n");
|
||||
clock.fire();
|
||||
await settle();
|
||||
|
||||
expect(disk()).toBe("latest\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false });
|
||||
});
|
||||
|
||||
test("keeps a newer edit modified when an older save finishes", async () => {
|
||||
const { model, disk } = await openEditor();
|
||||
|
||||
model.edit("saved\n");
|
||||
const save = model.save();
|
||||
model.edit("newer\n");
|
||||
await save;
|
||||
|
||||
expect(disk()).toBe("saved\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "dirty", modified: true });
|
||||
});
|
||||
|
||||
test("keeps CRLF content and the byte order mark in file form", async () => {
|
||||
const { model, disk } = await openEditor(
|
||||
new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode("a\r\nb\n")]),
|
||||
);
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ content: "a\r\nb\n", lineSeparator: "\r\n" });
|
||||
model.edit("c\r\nd\r\n");
|
||||
await model.save();
|
||||
|
||||
expect(disk()).toBe("c\r\nd\r\n");
|
||||
});
|
||||
|
||||
test("follows the disk while clean", async () => {
|
||||
const { session, model } = await openEditor();
|
||||
|
||||
session.put("source.ts", "external\r\n");
|
||||
await settle();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "clean",
|
||||
content: "external\r\n",
|
||||
lineSeparator: "\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("conflicts when the disk changes under a modified buffer, and overwrites", async () => {
|
||||
const { session, model, disk } = await openEditor();
|
||||
|
||||
model.edit("local\n");
|
||||
session.put("source.ts", "external\n");
|
||||
await settle();
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local\n" });
|
||||
expect(getFileConflictCallout(model.getSnapshot())).toEqual({
|
||||
kind: "changed",
|
||||
canOverwrite: true,
|
||||
});
|
||||
|
||||
await model.overwrite();
|
||||
expect(disk()).toBe("local\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "clean", conflict: null });
|
||||
});
|
||||
|
||||
test("reload discards a conflicted buffer for the disk", async () => {
|
||||
const { session, model } = await openEditor();
|
||||
|
||||
model.edit("local\n");
|
||||
session.put("source.ts", "external\n");
|
||||
await settle();
|
||||
await model.reload();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "clean",
|
||||
content: "external\n",
|
||||
modified: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a save over an unobserved change conflicts instead of writing", async () => {
|
||||
const { session, handle, model, disk } = await openEditor();
|
||||
|
||||
handle.setObserving(false);
|
||||
session.put("source.ts", "external\n");
|
||||
model.edit("local\n");
|
||||
await model.save();
|
||||
|
||||
expect(disk()).toBe("external\n");
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
|
||||
});
|
||||
|
||||
test("stays clean when the disk changes back to its own content", async () => {
|
||||
const { session, model, clock } = await openEditor();
|
||||
|
||||
model.edit("local\n");
|
||||
session.put("source.ts", "first\n");
|
||||
await settle();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local\n" });
|
||||
expect(clock.scheduled).toBe(true);
|
||||
});
|
||||
|
||||
test("a deletion conflicts with local changes and stops autosave", async () => {
|
||||
const { session, model, clock } = await openEditor();
|
||||
|
||||
model.edit("local\n");
|
||||
session.remove("source.ts");
|
||||
await settle();
|
||||
|
||||
expect(getFileConflictCallout(model.getSnapshot())).toEqual({ kind: "deleted" });
|
||||
expect(clock.scheduled).toBe(false);
|
||||
});
|
||||
|
||||
test("a save over a deletion it has not heard about shows the deleted notice", async () => {
|
||||
const { session, handle, model } = await openEditor();
|
||||
|
||||
handle.setObserving(false);
|
||||
session.remove("source.ts");
|
||||
model.edit("local\n");
|
||||
await model.save();
|
||||
|
||||
expect(getFileConflictCallout(model.getSnapshot())).toEqual({ kind: "deleted" });
|
||||
expect(model.getSnapshot().content).toBe("local\n");
|
||||
});
|
||||
|
||||
test("an external change during a save is reconciled after it", async () => {
|
||||
const { session, handle, model } = await openEditor();
|
||||
|
||||
model.edit("local\n");
|
||||
const resume = handle.holdWrites();
|
||||
const save = model.save();
|
||||
session.put("source.ts", "external\n");
|
||||
resume();
|
||||
await save;
|
||||
await settle();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({ status: "conflict", conflict: "changed" });
|
||||
});
|
||||
|
||||
test("reports failed saves without losing the buffer", async () => {
|
||||
const { session, model } = await openEditor();
|
||||
|
||||
session.failNextWrite("disk full");
|
||||
model.edit("local\n");
|
||||
await model.save();
|
||||
|
||||
expect(model.getSnapshot()).toMatchObject({
|
||||
status: "error",
|
||||
content: "local\n",
|
||||
error: "disk full",
|
||||
});
|
||||
});
|
||||
|
||||
test("flushes a modified buffer when disposed for another panel", async () => {
|
||||
const { model, disk } = await openEditor();
|
||||
|
||||
model.edit("switching\n");
|
||||
model.dispose({ flush: true });
|
||||
await settle();
|
||||
|
||||
expect(disk()).toBe("switching\n");
|
||||
});
|
||||
|
||||
test("discards the buffer when the tab closes while holding writes", async () => {
|
||||
const { handle, model, disk } = await openEditor();
|
||||
|
||||
model.edit("discarded\n");
|
||||
handle.holdWrites();
|
||||
model.dispose({ flush: true });
|
||||
handle.dispose();
|
||||
await settle();
|
||||
|
||||
expect(disk()).toBe("first\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
PluginFileDeletedError,
|
||||
type PluginFile,
|
||||
type PluginFileEvent,
|
||||
} from "@getpaseo/plugin/client";
|
||||
|
||||
export type FileEditorStatus =
|
||||
| "loading"
|
||||
| "failed"
|
||||
| "clean"
|
||||
| "dirty"
|
||||
| "saving"
|
||||
| "conflict"
|
||||
| "error";
|
||||
export type FileLineSeparator = "\n" | "\r\n" | "\r";
|
||||
|
||||
export interface FileEditorSnapshot {
|
||||
status: FileEditorStatus;
|
||||
content: string;
|
||||
lineSeparator: FileLineSeparator;
|
||||
/** The buffer differs from what this editor last read or saved. */
|
||||
modified: boolean;
|
||||
/** Why the editor is in conflict: the file changed or was deleted on disk. */
|
||||
conflict: "changed" | "deleted" | null;
|
||||
/** The failed load's or save's message. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type FileConflictCallout = { kind: "changed"; canOverwrite: boolean } | { kind: "deleted" };
|
||||
|
||||
export interface FileEditorClock {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||
clearTimeout(handle: ReturnType<typeof setTimeout>): void;
|
||||
}
|
||||
|
||||
const systemClock: FileEditorClock = {
|
||||
setTimeout(callback, delay) {
|
||||
return globalThis.setTimeout(callback, delay);
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
globalThis.clearTimeout(handle);
|
||||
},
|
||||
};
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
|
||||
/**
|
||||
* Source's buffer over a `PluginFile`: reads the file, autosaves edits, and reconciles changes on
|
||||
* disk. A clean buffer follows the disk; a modified one conflicts and the user overwrites or
|
||||
* reloads. The file handle rejects writes over versions this editor has not read.
|
||||
*/
|
||||
export class FileEditorModel {
|
||||
private readonly file: PluginFile;
|
||||
private readonly clock: FileEditorClock;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FileEditorSnapshot = {
|
||||
status: "loading",
|
||||
content: "",
|
||||
lineSeparator: "\n",
|
||||
modified: false,
|
||||
conflict: null,
|
||||
error: null,
|
||||
};
|
||||
private persisted = "";
|
||||
private autosave: ReturnType<typeof setTimeout> | null = null;
|
||||
private saving = false;
|
||||
private saveSequence = 0;
|
||||
private readSequence = 0;
|
||||
private eventDuringSave: PluginFileEvent | null = null;
|
||||
private unwatch: (() => void) | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(input: { file: PluginFile; clock?: FileEditorClock }) {
|
||||
this.file = input.file;
|
||||
this.clock = input.clock ?? systemClock;
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): FileEditorSnapshot => this.snapshot;
|
||||
|
||||
/** Reads the file and follows its changes on disk. */
|
||||
open(): void {
|
||||
if (this.disposed || this.unwatch) return;
|
||||
this.unwatch = this.file.watch((event) => this.receiveFileEvent(event));
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
edit(content: string): void {
|
||||
if (this.disposed || !this.isLoaded() || content === this.snapshot.content) return;
|
||||
const modified = content !== this.persisted;
|
||||
let status: FileEditorStatus = modified ? "dirty" : "clean";
|
||||
if (this.snapshot.status === "conflict") status = "conflict";
|
||||
this.setSnapshot({ ...this.snapshot, status, content, modified, error: null });
|
||||
if (status === "dirty") this.scheduleAutosave();
|
||||
else this.clearAutosave();
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.snapshot.status !== "dirty" && this.snapshot.status !== "error") return;
|
||||
await this.write({ force: false });
|
||||
}
|
||||
|
||||
/** Writes the buffer over a file that changed on disk. */
|
||||
async overwrite(): Promise<void> {
|
||||
if (this.snapshot.conflict !== "changed") return;
|
||||
await this.write({ force: true });
|
||||
}
|
||||
|
||||
/** Replaces the buffer with the file on disk. */
|
||||
async reload(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
const sequence = ++this.readSequence;
|
||||
try {
|
||||
const content = await this.file.readText();
|
||||
if (this.disposed || sequence !== this.readSequence) return;
|
||||
this.applyFile(content);
|
||||
} catch (error) {
|
||||
if (this.disposed || sequence !== this.readSequence) return;
|
||||
if (this.isLoaded()) return;
|
||||
this.setSnapshot({ ...this.snapshot, status: "failed", error: errorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops following the file. With `flush`, a modified buffer is written first, so switching to
|
||||
* another panel keeps the edit. The tab holds that write while it asks to discard it.
|
||||
*/
|
||||
dispose({ flush }: { flush: boolean }): void {
|
||||
if (this.disposed) return;
|
||||
const status = this.snapshot.status;
|
||||
if (flush && this.snapshot.modified && status !== "conflict") {
|
||||
void this.file.writeText(this.snapshot.content).catch(() => undefined);
|
||||
}
|
||||
this.disposed = true;
|
||||
this.clearAutosave();
|
||||
this.unwatch?.();
|
||||
this.unwatch = null;
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private receiveFileEvent(event: PluginFileEvent): void {
|
||||
if (this.disposed) return;
|
||||
if (!this.isLoaded()) {
|
||||
if (event.type === "changed") void this.reload();
|
||||
return;
|
||||
}
|
||||
if (this.saving) {
|
||||
this.eventDuringSave = event;
|
||||
return;
|
||||
}
|
||||
if (event.type === "deleted") {
|
||||
this.enterConflict("deleted");
|
||||
return;
|
||||
}
|
||||
void this.reconcile();
|
||||
}
|
||||
|
||||
/** Follows a clean buffer to the disk; a modified one conflicts unless the disk matches it. */
|
||||
private async reconcile(): Promise<void> {
|
||||
const sequence = ++this.readSequence;
|
||||
let content: string;
|
||||
try {
|
||||
content = await this.file.readText();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (this.disposed || sequence !== this.readSequence) return;
|
||||
if (this.saving) {
|
||||
this.eventDuringSave = { type: "changed" };
|
||||
return;
|
||||
}
|
||||
if (content === this.persisted) {
|
||||
this.adoptUnchangedFile();
|
||||
return;
|
||||
}
|
||||
if (!this.snapshot.modified) {
|
||||
this.applyFile(content);
|
||||
return;
|
||||
}
|
||||
this.enterConflict("changed");
|
||||
}
|
||||
|
||||
private async write({ force }: { force: boolean }): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.clearAutosave();
|
||||
const sequence = ++this.saveSequence;
|
||||
const content = this.snapshot.content;
|
||||
this.saving = true;
|
||||
this.eventDuringSave = null;
|
||||
this.setSnapshot({ ...this.snapshot, status: "saving", conflict: null, error: null });
|
||||
try {
|
||||
await this.file.writeText(content, { force });
|
||||
} catch (error) {
|
||||
this.saving = false;
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
if (error instanceof PluginFileConflictError) this.enterConflict("changed");
|
||||
else if (error instanceof PluginFileDeletedError) this.enterConflict("deleted");
|
||||
else this.setSnapshot({ ...this.snapshot, status: "error", error: errorMessage(error) });
|
||||
this.receivePendingEvent();
|
||||
return;
|
||||
}
|
||||
this.saving = false;
|
||||
if (this.disposed || sequence !== this.saveSequence) return;
|
||||
this.persisted = content;
|
||||
const modified = this.snapshot.content !== this.persisted;
|
||||
this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified });
|
||||
if (modified) this.scheduleAutosave();
|
||||
this.receivePendingEvent();
|
||||
}
|
||||
|
||||
private receivePendingEvent(): void {
|
||||
const event = this.eventDuringSave;
|
||||
this.eventDuringSave = null;
|
||||
if (event) this.receiveFileEvent(event);
|
||||
}
|
||||
|
||||
private applyFile(content: string): void {
|
||||
this.clearAutosave();
|
||||
this.saveSequence += 1;
|
||||
this.persisted = content;
|
||||
this.setSnapshot({
|
||||
status: "clean",
|
||||
content,
|
||||
lineSeparator: detectLineSeparator(content),
|
||||
modified: false,
|
||||
conflict: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
private adoptUnchangedFile(): void {
|
||||
if (this.snapshot.status !== "conflict") return;
|
||||
const status = this.snapshot.modified ? "dirty" : "clean";
|
||||
this.setSnapshot({ ...this.snapshot, status, conflict: null, error: null });
|
||||
if (status === "dirty") this.scheduleAutosave();
|
||||
}
|
||||
|
||||
private enterConflict(conflict: "changed" | "deleted"): void {
|
||||
this.clearAutosave();
|
||||
this.setSnapshot({ ...this.snapshot, status: "conflict", conflict, error: null });
|
||||
}
|
||||
|
||||
private isLoaded(): boolean {
|
||||
return this.snapshot.status !== "loading" && this.snapshot.status !== "failed";
|
||||
}
|
||||
|
||||
private scheduleAutosave(): void {
|
||||
this.clearAutosave();
|
||||
this.autosave = this.clock.setTimeout(() => {
|
||||
this.autosave = null;
|
||||
void this.save();
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
private clearAutosave(): void {
|
||||
if (!this.autosave) return;
|
||||
this.clock.clearTimeout(this.autosave);
|
||||
this.autosave = null;
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: FileEditorSnapshot): void {
|
||||
this.snapshot = snapshot;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null {
|
||||
if (snapshot.status !== "conflict" || !snapshot.conflict) return null;
|
||||
if (snapshot.conflict === "deleted") return { kind: "deleted" };
|
||||
return { kind: "changed", canOverwrite: snapshot.modified };
|
||||
}
|
||||
|
||||
function detectLineSeparator(content: string): FileLineSeparator {
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const character = content.charCodeAt(index);
|
||||
if (character === 10) return "\n";
|
||||
if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r";
|
||||
}
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
+1
@@ -22,6 +22,7 @@ export function FileEditorView(_props: {
|
||||
};
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
onReady(): void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
+17
-3
@@ -4,7 +4,6 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import { getCM, vim } from "@replit/codemirror-vim";
|
||||
import { isRenderedMarkdownFile } from "@/file-panels";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { FileEditorModel } from "./model";
|
||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
||||
@@ -18,6 +17,8 @@ interface FileEditorViewProps {
|
||||
theme: EditorVisualTheme;
|
||||
onCursorChange(position: { line: number; column: number }): void;
|
||||
onVimModeChange(mode: string | null): void;
|
||||
/** The editor shows the file's content. */
|
||||
onReady(): void;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
@@ -25,8 +26,12 @@ const wrappingCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
/** Markdown is prose, so its paragraphs wrap; code keeps its lines. */
|
||||
const WRAPPED_EXTENSIONS = new Set(["md", "markdown"]);
|
||||
|
||||
function wrappingForFile(filename: string) {
|
||||
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
|
||||
const extension = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return WRAPPED_EXTENSIONS.has(extension) ? EditorView.lineWrapping : [];
|
||||
}
|
||||
|
||||
export function FileEditorView({
|
||||
@@ -38,12 +43,20 @@ export function FileEditorView({
|
||||
theme,
|
||||
onCursorChange,
|
||||
onVimModeChange,
|
||||
onReady,
|
||||
}: FileEditorViewProps) {
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
||||
const initial = useRef({
|
||||
filename,
|
||||
model,
|
||||
theme,
|
||||
vimEnabled,
|
||||
content: snapshot.content,
|
||||
onReady,
|
||||
});
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
|
||||
@@ -80,6 +93,7 @@ export function FileEditorView({
|
||||
});
|
||||
viewRef.current = view;
|
||||
onCursorChangeRef.current({ line: 1, column: 1 });
|
||||
values.onReady();
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useFileText } from "@getpaseo/plugin/client";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { FilePanelLoading } from "../loading";
|
||||
import type { BuiltinFilePanelProps } from "../panels";
|
||||
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
|
||||
import { FileEditorModel, getFileConflictCallout } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { selectSourcePresentation } from "./readonly/presentation";
|
||||
import { FileSourceView } from "./readonly/view";
|
||||
import { SourceStatusRow } from "./status";
|
||||
|
||||
// CodeMirror and native source rendering consume appearance as props. Subscribe at the
|
||||
// rendering leaf so theme updates reconfigure it without replacing the editor/model.
|
||||
const sourceAppearance = (theme: Theme) => ({
|
||||
theme: {
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
},
|
||||
});
|
||||
const ThemedFileEditorView = withUnistyles(FileEditorView, sourceAppearance);
|
||||
const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance);
|
||||
|
||||
/** The daemon refuses to write larger files. */
|
||||
const MAX_EDITABLE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file
|
||||
* on native read-only, and refuses files above the display budget before reading them.
|
||||
*/
|
||||
export function SourcePanel(props: BuiltinFilePanelProps) {
|
||||
const size = props.file.size;
|
||||
const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" });
|
||||
if (presentation === "unsupported") return <TooLargeSource size={size} />;
|
||||
if (isWeb && size <= MAX_EDITABLE_BYTES) return <EditableSource {...props} />;
|
||||
return <ReadonlySource {...props} presentation={presentation} />;
|
||||
}
|
||||
|
||||
function TooLargeSource({ size }: { size: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.centerState} testID="file-source-too-large">
|
||||
<Text style={styles.mutedText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||
<Text style={styles.mutedText}>{formatFileSize(size)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlySource({
|
||||
file,
|
||||
location,
|
||||
target,
|
||||
presentation,
|
||||
}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) {
|
||||
const text = useFileText(file);
|
||||
const { t } = useTranslation();
|
||||
const sourceLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
);
|
||||
if (text.status === "loading") return <FilePanelLoading />;
|
||||
if (text.status === "error") {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{text.error.message || t("panels.file.failedToLoad")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ThemedFileSourceView
|
||||
content={text.text}
|
||||
filename={target.path}
|
||||
location={sourceLocation}
|
||||
navigationRevision={location.revision}
|
||||
presentation={presentation}
|
||||
/>
|
||||
<SourceStatusRow size={file.size} lineCount={text.text.split("\n").length} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [model] = useState(() => new FileEditorModel({ file }));
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
model.open();
|
||||
return () => model.dispose({ flush: true });
|
||||
}, [model]);
|
||||
useEffect(() => {
|
||||
tab.setModified(snapshot.modified);
|
||||
}, [snapshot.modified, tab]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!snapshot.modified) {
|
||||
void model.reload();
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("panels.file.editor.reloadTitle"),
|
||||
message: t("panels.file.editor.reloadMessage"),
|
||||
confirmLabel: t("panels.file.editor.reload"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) void model.reload();
|
||||
})();
|
||||
}, [model, snapshot.modified, t]);
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const handleRetry = useCallback(() => void model.reload(), [model]);
|
||||
const handleReady = useCallback(() => setEditorReady(true), []);
|
||||
const editorLocation = useMemo(
|
||||
() => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }),
|
||||
[location.lineEnd, location.lineStart, target.path],
|
||||
);
|
||||
|
||||
if (snapshot.status === "loading") return <FilePanelLoading />;
|
||||
if (snapshot.status === "failed") {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{snapshot.error ?? t("panels.file.failedToLoad")}</Text>
|
||||
<Button variant="outline" size="sm" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const callout = getFileConflictCallout(snapshot);
|
||||
let conflict: FileConflictAlertState | null = null;
|
||||
if (callout?.kind === "deleted") conflict = { kind: "deleted" };
|
||||
else if (callout) {
|
||||
conflict = {
|
||||
kind: "changed",
|
||||
canOverwrite: callout.canOverwrite,
|
||||
onReload: handleReload,
|
||||
onOverwrite: handleOverwrite,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{conflict ? <FileConflictAlert state={conflict} /> : null}
|
||||
<View style={styles.editor}>
|
||||
<ThemedFileEditorView
|
||||
model={model}
|
||||
filename={target.path}
|
||||
location={editorLocation}
|
||||
navigationRevision={location.revision}
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={setVimMode}
|
||||
onReady={handleReady}
|
||||
/>
|
||||
{editorReady ? null : <FilePanelLoading />}
|
||||
</View>
|
||||
<SourceStatusRow
|
||||
size={file.size}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
editorStatus={snapshot.status}
|
||||
vimMode={vimMode}
|
||||
cursor={cursor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
editor: { flex: 1, minHeight: 0 },
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,67 @@
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSourceLines } from "./source-lines";
|
||||
|
||||
const large = Array.from({ length: 14000 }, (_, i) => `export const item${i} = ${i};`).join("\n");
|
||||
|
||||
/** Collects every report until `count` have arrived. */
|
||||
function build(
|
||||
input: { presentation: "highlighted" | "plain"; content?: string; firstLines?: number },
|
||||
count = 1,
|
||||
) {
|
||||
return new Promise<HighlightToken[][][]>((resolve) => {
|
||||
const reports: HighlightToken[][][] = [];
|
||||
buildSourceLines(
|
||||
{
|
||||
content: input.content ?? large,
|
||||
filename: "large.ts",
|
||||
presentation: input.presentation,
|
||||
firstLines: input.firstLines ?? 0,
|
||||
},
|
||||
(lines) => {
|
||||
reports.push(lines);
|
||||
if (reports.length === count) resolve(reports);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildSourceLines", () => {
|
||||
it("reports the first lines, then the whole file, leaving the thread free between slices", async () => {
|
||||
let ticks = 0;
|
||||
const ticker = setInterval(() => ticks++, 0);
|
||||
const [first, whole] = await build({ presentation: "highlighted", firstLines: 300 }, 2);
|
||||
clearInterval(ticker);
|
||||
|
||||
const highlighted = highlightCode(large, "large.ts");
|
||||
expect(first.slice(0, 300)).toEqual(highlighted.slice(0, 300));
|
||||
expect(first[300]).toEqual([{ text: "export const item300 = 300;", style: null }]);
|
||||
expect(whole).toEqual(highlighted);
|
||||
expect(ticks).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("splits plain lines without highlighting them", async () => {
|
||||
expect(await build({ presentation: "plain", content: "const a = 1;\n\nb" })).toEqual([
|
||||
[
|
||||
[{ text: "const a = 1;", style: null }],
|
||||
[{ text: "", style: null }],
|
||||
[{ text: "b", style: null }],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("never reports lines once cancelled", async () => {
|
||||
let reported = false;
|
||||
const cancel = buildSourceLines(
|
||||
{ content: large, filename: "large.ts", presentation: "highlighted", firstLines: 0 },
|
||||
() => {
|
||||
reported = true;
|
||||
},
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
cancel();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(reported).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { highlightCodeInSteps, type HighlightToken } from "@getpaseo/highlight";
|
||||
|
||||
/** JavaScript time per slice; the rest of the frame stays free for input and rendering. */
|
||||
const SLICE_BUDGET_MS = 12;
|
||||
const PLAIN_LINES_PER_UNIT = 2000;
|
||||
|
||||
/**
|
||||
* Splits `content` into source lines, highlighted or plain, a time slice per task so a large file
|
||||
* never blocks the JavaScript thread. Highlighting reports `firstLines` lines highlighted, the
|
||||
* rest plain, before the whole file. Returns a cancel function.
|
||||
*/
|
||||
export function buildSourceLines(
|
||||
input: {
|
||||
content: string;
|
||||
filename: string;
|
||||
presentation: "highlighted" | "plain";
|
||||
firstLines: number;
|
||||
},
|
||||
onLines: (lines: HighlightToken[][]) => void,
|
||||
): () => void {
|
||||
const steps =
|
||||
input.presentation === "highlighted"
|
||||
? highlightCodeInSteps(input.content, input.filename, { firstLines: input.firstLines })
|
||||
: plainLinesInSteps(input.content);
|
||||
let cancelSlice = scheduleSlice(runSlice);
|
||||
function runSlice() {
|
||||
const deadline = performance.now() + SLICE_BUDGET_MS;
|
||||
do {
|
||||
const step = steps.next();
|
||||
if (step.done) {
|
||||
onLines(step.value);
|
||||
return;
|
||||
}
|
||||
if (step.value) onLines(step.value);
|
||||
} while (performance.now() < deadline);
|
||||
cancelSlice = scheduleSlice(runSlice);
|
||||
}
|
||||
return () => cancelSlice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `slice` once the JavaScript thread is idle. React Native runs idle callbacks after pending
|
||||
* events and renders without waiting for a frame; a zero timeout waits for the next frame.
|
||||
*/
|
||||
function scheduleSlice(slice: () => void): () => void {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
const id = requestIdleCallback(slice);
|
||||
return () => cancelIdleCallback(id);
|
||||
}
|
||||
const timer = setTimeout(slice, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
function* plainLinesInSteps(content: string): Generator<undefined, HighlightToken[][], void> {
|
||||
const lines: HighlightToken[][] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const end = content.indexOf("\n", from);
|
||||
lines.push([{ text: content.slice(from, end === -1 ? content.length : end), style: null }]);
|
||||
if (end === -1) return lines;
|
||||
from = end + 1;
|
||||
if (lines.length % PLAIN_LINES_PER_UNIT === 0) yield;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { FilePanelLoading } from "../../loading";
|
||||
import type { SourcePresentation } from "./presentation";
|
||||
import { buildSourceLines } from "./source-lines";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
theme: EditorVisualTheme;
|
||||
}
|
||||
|
||||
/** Lines past the opening line highlighted before the rest of a large file. */
|
||||
const FIRST_LINES = 200;
|
||||
|
||||
/**
|
||||
* A virtualized, read-only source view. Shows Loading until the lines around the opening line are
|
||||
* highlighted; the rest highlight in the background. A changed file keeps showing the previous
|
||||
* lines until its own are ready.
|
||||
*/
|
||||
export function FileSourceView({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
presentation,
|
||||
}: FileSourceViewProps) {
|
||||
const listRef = useRef<FlatList<HighlightToken[]>>(null);
|
||||
const [lines, setLines] = useState<HighlightToken[][] | null>(null);
|
||||
// Navigating within the file does not rebuild its lines; a new build starts at the latest line.
|
||||
const openingLine = useRef(location.lineStart);
|
||||
useEffect(() => {
|
||||
openingLine.current = location.lineStart;
|
||||
});
|
||||
useEffect(() => {
|
||||
const firstLines = (openingLine.current ?? 1) + FIRST_LINES;
|
||||
return buildSourceLines({ content, filename, presentation, firstLines }, setLines);
|
||||
}, [content, filename, presentation]);
|
||||
const lineCount = lines?.length ?? 0;
|
||||
useEffect(() => {
|
||||
if (!location.lineStart || lineCount === 0) return;
|
||||
listRef.current?.scrollToIndex({
|
||||
index: Math.min(location.lineStart - 1, lineCount - 1),
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [lineCount, location.lineStart, navigationRevision]);
|
||||
if (!lines) return <FilePanelLoading />;
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
getItemLayout={sourceLineLayout}
|
||||
renderItem={renderSourceLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceLineProps {
|
||||
tokens: HighlightToken[];
|
||||
number: number;
|
||||
}
|
||||
|
||||
// Finishing the highlight replaces every line; the lines already highlighted stay as they were.
|
||||
const SourceLineView = memo(function SourceLineView({ tokens, number }: SourceLineProps) {
|
||||
// A token's offset in its line identifies it; its text and style can repeat.
|
||||
let offset = 0;
|
||||
return (
|
||||
<View style={styles.line}>
|
||||
<Text style={styles.gutter}>{number}</Text>
|
||||
<Text selectable style={styles.text}>
|
||||
{tokens.map((token) => {
|
||||
const key = offset;
|
||||
offset += token.text.length;
|
||||
return (
|
||||
<Text key={key} style={syntaxTokenStyleFor(token.style)}>
|
||||
{token.text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, sameSourceLine);
|
||||
|
||||
function sameSourceLine(previous: SourceLineProps, next: SourceLineProps): boolean {
|
||||
return (
|
||||
previous.number === next.number &&
|
||||
previous.tokens.length === next.tokens.length &&
|
||||
previous.tokens.every(
|
||||
(token, index) =>
|
||||
token.text === next.tokens[index].text && token.style === next.tokens[index].style,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLineKey(_tokens: HighlightToken[], index: number): string {
|
||||
return String(index);
|
||||
}
|
||||
|
||||
function sourceLineLayout(_data: ArrayLike<HighlightToken[]> | null | undefined, index: number) {
|
||||
return { length: 20, offset: index * 20, index };
|
||||
}
|
||||
|
||||
const renderSourceLine: ListRenderItem<HighlightToken[]> = ({ item, index }) => (
|
||||
<SourceLineView tokens={item} number={index + 1} />
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
|
||||
gutter: {
|
||||
width: 56,
|
||||
paddingRight: theme.spacing[3],
|
||||
textAlign: "right",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
},
|
||||
}));
|
||||
+8
-41
@@ -6,63 +6,34 @@ import { getLanguageForFile } from "@getpaseo/highlight";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||
import { editorTheme } from "../editor/extensions.web";
|
||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||
import { FilePanelLoading } from "../../loading";
|
||||
import type { SourcePresentation } from "./presentation";
|
||||
|
||||
interface FileSourceViewProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
size: number;
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
theme: EditorVisualTheme;
|
||||
tooLargeMessage: string;
|
||||
}
|
||||
|
||||
const languageCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
|
||||
/** A read-only source view for files Source does not edit. Shows Loading until the editor exists. */
|
||||
export function FileSourceView({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
size,
|
||||
theme,
|
||||
tooLargeMessage,
|
||||
}: FileSourceViewProps) {
|
||||
const presentation = selectSourcePresentation({ size, platform: "web" });
|
||||
if (presentation === "unsupported") {
|
||||
return (
|
||||
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
|
||||
{tooLargeMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ReadonlyCodeMirror
|
||||
content={content}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
presentation={presentation}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadonlyCodeMirror({
|
||||
content,
|
||||
filename,
|
||||
location,
|
||||
navigationRevision,
|
||||
presentation,
|
||||
theme,
|
||||
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||
}) {
|
||||
}: FileSourceViewProps) {
|
||||
const [find] = useState(() => new FileFindModel());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const initial = useRef({ content, filename, presentation, theme });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,6 +59,7 @@ function ReadonlyCodeMirror({
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
setReady(true);
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
@@ -121,6 +93,7 @@ function ReadonlyCodeMirror({
|
||||
<div style={FRAME_STYLE}>
|
||||
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
|
||||
<FileFind model={find} editor={viewRef} />
|
||||
{ready ? null : <FilePanelLoading />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,9 +115,3 @@ const FRAME_STYLE = {
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
const UNSUPPORTED_STYLE = {
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
} as const;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { formatFileSize } from "@/utils/format-file-size";
|
||||
import type { FileEditorStatus } from "./editor/model";
|
||||
|
||||
const ThemedSpinner = withUnistyles(LoadingSpinner);
|
||||
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */
|
||||
export function SourceStatusRow({
|
||||
size,
|
||||
lineCount,
|
||||
editorStatus,
|
||||
vimMode,
|
||||
cursor,
|
||||
}: {
|
||||
size: number;
|
||||
lineCount: number;
|
||||
editorStatus?: FileEditorStatus;
|
||||
vimMode?: string | null;
|
||||
cursor?: { line: number; column: number };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.row} testID="file-source-status">
|
||||
<View style={styles.metadata}>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.fileSize", { size: formatFileSize(size) })}
|
||||
>
|
||||
{formatFileSize(size)}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.whisper}
|
||||
numberOfLines={1}
|
||||
accessibilityLabel={t("panels.file.editor.lines", { count: lineCount })}
|
||||
>
|
||||
{t("panels.file.editor.lines", { count: lineCount })}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={styles.status}
|
||||
accessibilityLabel={
|
||||
editorStatus ? t("panels.file.editor.editorStatus", { status: editorStatus }) : undefined
|
||||
}
|
||||
>
|
||||
{editorStatus === "dirty" ? (
|
||||
<View
|
||||
style={styles.dirtyDot}
|
||||
accessibilityLabel={t("panels.file.editor.unsavedChanges")}
|
||||
/>
|
||||
) : null}
|
||||
{editorStatus === "saving" ? (
|
||||
<>
|
||||
<ThemedSpinner size={14} uniProps={spinnerMapping} />
|
||||
<Text style={styles.secondary}>{t("panels.file.editor.saving")}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{editorStatus === "error" ? (
|
||||
<Text style={styles.error}>{t("panels.file.editor.saveFailed")}</Text>
|
||||
) : null}
|
||||
{vimMode ? (
|
||||
<Text
|
||||
style={styles.vim}
|
||||
accessibilityLabel={t("panels.file.editor.vimMode", { mode: vimMode })}
|
||||
>
|
||||
{vimMode}
|
||||
</Text>
|
||||
) : null}
|
||||
{cursor ? (
|
||||
<Text style={styles.whisper} accessibilityLabel={t("panels.file.editor.cursor", cursor)}>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexShrink: 0,
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
metadata: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
status: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm },
|
||||
error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm },
|
||||
dirtyDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
vim: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FilePanel, BuiltinFilePanelProps } from "./panels";
|
||||
import { BuiltinFilePanelView } from "./builtin";
|
||||
import { PluginFilePanelHost } from "./plugin-host";
|
||||
|
||||
/** Renders a file panel as the file tab's body. The panel owns its loading, errors and saving. */
|
||||
export function FilePanelView({ panel, ...props }: BuiltinFilePanelProps & { panel: FilePanel }) {
|
||||
if (panel.builtin) return <BuiltinFilePanelView {...props} panel={panel} />;
|
||||
return <PluginFilePanelHost {...props} panel={panel} />;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AgentFileExplorerState,
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealExplorerDirectory } from "@/file-explorer/tree";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
@@ -13,7 +12,6 @@ import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
return {
|
||||
directories: new Map(),
|
||||
files: new Map(),
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
@@ -148,7 +146,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
const directories = new Map(state.directories);
|
||||
@@ -195,70 +192,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[requestDirectoryListing, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
async (path: string) => {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: normalizedPath, mode: "file" },
|
||||
}));
|
||||
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: t("workspace.fileExplorer.states.unavailable"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: t("workspace.terminal.hostDisconnected"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
|
||||
updateExplorerState((state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
const files = new Map(state.files);
|
||||
const explorerFile = explorerFileFromReadResult(file);
|
||||
files.set(explorerFile.path, explorerFile);
|
||||
nextState.files = files;
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} catch (error) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : t("panels.file.failedToLoadPreview"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestFileDownloadToken = useCallback(
|
||||
async (path: string) => {
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
@@ -379,7 +312,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
workspaceStateKey,
|
||||
revealDirectory,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
|
||||
@@ -1918,12 +1918,32 @@ export const ar: TranslationResources = {
|
||||
creatingAgent: "وكيل الخلق",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "فتح ملفات .{{extension}} باستخدام",
|
||||
pickerTitleFilename: "فتح {{filename}} باستخدام",
|
||||
dontAskAgain: "لا تسألني مرة أخرى",
|
||||
use: "استخدام",
|
||||
openWith: "فتح باستخدام…",
|
||||
alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}",
|
||||
alwaysUseFilename: "استخدام دائمًا لـ {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "إجراءات الملف",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "صورة",
|
||||
source: "المصدر",
|
||||
},
|
||||
},
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
loading: "جارٍ تحميل الملف...",
|
||||
noPreview: "لا تتوفر معاينة",
|
||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||
documentUnsupported: "لا يمكن عرض ملفات {{type}} على هذا الجهاز",
|
||||
failedToLoad: "فشل تحميل الملف",
|
||||
notFound: "الملف غير موجود",
|
||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||
editor: {
|
||||
fileSize: "حجم الملف {{size}}",
|
||||
@@ -1935,10 +1955,7 @@ export const ar: TranslationResources = {
|
||||
changedOnDisk: "تغيّر على القرص",
|
||||
vimMode: "وضع Vim {{mode}}",
|
||||
cursor: "السطر {{line}}، العمود {{column}}",
|
||||
preview: "معاينة",
|
||||
source: "المصدر",
|
||||
deletedTitle: "تم حذف الملف من القرص",
|
||||
checkFailedTitle: "تعذّر التحقق من الملف على القرص",
|
||||
preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.",
|
||||
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
|
||||
overwrite: "استبدال",
|
||||
@@ -2174,6 +2191,7 @@ export const ar: TranslationResources = {
|
||||
terminal: "الطرفية",
|
||||
browser: "المتصفح",
|
||||
editor: "المحرر",
|
||||
files: "الملفات",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
notifications: "الإشعارات",
|
||||
@@ -2187,6 +2205,14 @@ export const ar: TranslationResources = {
|
||||
vimKeybindings: "اختصارات Vim",
|
||||
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
|
||||
},
|
||||
files: {
|
||||
title: "الملفات",
|
||||
askEveryTime: "اسأل في كل مرة",
|
||||
askAgain: "اسأل مجددًا",
|
||||
askAgainFor: "اسأل مجددًا عن {{label}}",
|
||||
unavailable: "غير موجود على مضيف متصل",
|
||||
empty: "تظهر لوحات الملفات هنا عندما تضيف إحدى الإضافات لوحة",
|
||||
},
|
||||
notifications: {
|
||||
title: "الإشعارات",
|
||||
permission: "إذن الإشعارات",
|
||||
|
||||
@@ -1943,12 +1943,32 @@ export const en = {
|
||||
creatingAgent: "Creating agent",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "Open .{{extension}} files with",
|
||||
pickerTitleFilename: "Open {{filename}} with",
|
||||
dontAskAgain: "Don't ask me again",
|
||||
use: "Use",
|
||||
openWith: "Open with…",
|
||||
alwaysUseExtension: "Always use for .{{extension}}",
|
||||
alwaysUseFilename: "Always use for {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "File actions",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "Image",
|
||||
source: "Source",
|
||||
},
|
||||
},
|
||||
directoryMissing: "Workspace directory not found.",
|
||||
loading: "Loading file...",
|
||||
noPreview: "No preview available",
|
||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||
tooLargeToDisplay: "This file is too large to display",
|
||||
documentUnsupported: "{{type}} files can't be shown on this device",
|
||||
failedToLoad: "Failed to load file",
|
||||
notFound: "File not found",
|
||||
failedToLoadPreview: "Failed to load file preview",
|
||||
editor: {
|
||||
fileSize: "File size {{size}}",
|
||||
@@ -1960,10 +1980,7 @@ export const en = {
|
||||
changedOnDisk: "Changed on disk",
|
||||
vimMode: "Vim mode {{mode}}",
|
||||
cursor: "Line {{line}}, column {{column}}",
|
||||
preview: "Preview",
|
||||
source: "Source",
|
||||
deletedTitle: "File deleted on disk",
|
||||
checkFailedTitle: "Couldn't check file on disk",
|
||||
preservedDescription: "The open copy is preserved.",
|
||||
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
|
||||
overwrite: "Overwrite",
|
||||
@@ -2161,6 +2178,7 @@ export const en = {
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
files: "Files",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
@@ -2214,6 +2232,14 @@ export const en = {
|
||||
vimKeybindings: "Vim keybindings",
|
||||
vimHint: "Applies to source files on web and desktop.",
|
||||
},
|
||||
files: {
|
||||
title: "Files",
|
||||
askEveryTime: "Ask every time",
|
||||
askAgain: "Ask again",
|
||||
askAgainFor: "Ask again for {{label}}",
|
||||
unavailable: "Not on a connected host",
|
||||
empty: "File panels appear here when a plugin adds one",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
permission: "Notification permission",
|
||||
|
||||
@@ -1966,12 +1966,32 @@ export const es: TranslationResources = {
|
||||
creatingAgent: "Agente creador",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "Abrir archivos .{{extension}} con",
|
||||
pickerTitleFilename: "Abrir {{filename}} con",
|
||||
dontAskAgain: "No volver a preguntar",
|
||||
use: "Usar",
|
||||
openWith: "Abrir con…",
|
||||
alwaysUseExtension: "Usar siempre para .{{extension}}",
|
||||
alwaysUseFilename: "Usar siempre para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Acciones del archivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "Imagen",
|
||||
source: "Código fuente",
|
||||
},
|
||||
},
|
||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||
loading: "Cargando archivo...",
|
||||
noPreview: "No hay vista previa disponible",
|
||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
|
||||
documentUnsupported: "Los archivos {{type}} no se pueden mostrar en este dispositivo",
|
||||
failedToLoad: "No se pudo cargar el archivo",
|
||||
notFound: "No se encontró el archivo",
|
||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||
editor: {
|
||||
fileSize: "Tamaño {{size}}",
|
||||
@@ -1983,10 +2003,7 @@ export const es: TranslationResources = {
|
||||
changedOnDisk: "Cambiado en disco",
|
||||
vimMode: "Modo Vim {{mode}}",
|
||||
cursor: "Línea {{line}}, columna {{column}}",
|
||||
preview: "Vista previa",
|
||||
source: "Código fuente",
|
||||
deletedTitle: "Archivo eliminado del disco",
|
||||
checkFailedTitle: "No se pudo comprobar el archivo en disco",
|
||||
preservedDescription: "La copia abierta se ha conservado.",
|
||||
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
|
||||
overwrite: "Sobrescribir",
|
||||
@@ -2186,6 +2203,7 @@ export const es: TranslationResources = {
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
files: "Archivos",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
notifications: "Notificaciones",
|
||||
@@ -2199,6 +2217,14 @@ export const es: TranslationResources = {
|
||||
vimKeybindings: "Atajos de Vim",
|
||||
vimHint: "Se aplica a archivos fuente en web y escritorio.",
|
||||
},
|
||||
files: {
|
||||
title: "Archivos",
|
||||
askEveryTime: "Preguntar siempre",
|
||||
askAgain: "Volver a preguntar",
|
||||
askAgainFor: "Volver a preguntar para {{label}}",
|
||||
unavailable: "No está en un host conectado",
|
||||
empty: "Los paneles de archivo aparecen aquí cuando un plugin añade uno",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notificaciones",
|
||||
permission: "Permiso de notificaciones",
|
||||
|
||||
@@ -1964,12 +1964,32 @@ export const fr: TranslationResources = {
|
||||
creatingAgent: "Création de l’agent",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "Ouvrir les fichiers .{{extension}} avec",
|
||||
pickerTitleFilename: "Ouvrir {{filename}} avec",
|
||||
dontAskAgain: "Ne plus demander",
|
||||
use: "Utiliser",
|
||||
openWith: "Ouvrir avec…",
|
||||
alwaysUseExtension: "Toujours utiliser pour .{{extension}}",
|
||||
alwaysUseFilename: "Toujours utiliser pour {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Actions du fichier",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "Image",
|
||||
source: "Source",
|
||||
},
|
||||
},
|
||||
directoryMissing: "Répertoire de l’espace de travail introuvable.",
|
||||
loading: "Chargement du fichier…",
|
||||
noPreview: "Aucun aperçu disponible",
|
||||
binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire",
|
||||
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
|
||||
documentUnsupported: "Les fichiers {{type}} ne peuvent pas être affichés sur cet appareil",
|
||||
failedToLoad: "Impossible de charger le fichier",
|
||||
notFound: "Fichier introuvable",
|
||||
failedToLoadPreview: "Impossible de charger l’aperçu du fichier",
|
||||
editor: {
|
||||
fileSize: "Taille du fichier : {{size}}",
|
||||
@@ -1981,10 +2001,7 @@ export const fr: TranslationResources = {
|
||||
changedOnDisk: "Modifié sur le disque",
|
||||
vimMode: "Mode Vim {{mode}}",
|
||||
cursor: "Ligne {{line}}, colonne {{column}}",
|
||||
preview: "Aperçu",
|
||||
source: "Source",
|
||||
deletedTitle: "Fichier supprimé du disque",
|
||||
checkFailedTitle: "Impossible de vérifier le fichier sur le disque",
|
||||
preservedDescription: "La copie ouverte est conservée.",
|
||||
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
|
||||
overwrite: "Écraser",
|
||||
@@ -2186,6 +2203,7 @@ export const fr: TranslationResources = {
|
||||
terminal: "Terminal",
|
||||
browser: "Navigateur",
|
||||
editor: "Éditeur",
|
||||
files: "Fichiers",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
notifications: "Notifications",
|
||||
@@ -2199,6 +2217,14 @@ export const fr: TranslationResources = {
|
||||
vimKeybindings: "Raccourcis Vim",
|
||||
vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.",
|
||||
},
|
||||
files: {
|
||||
title: "Fichiers",
|
||||
askEveryTime: "Toujours demander",
|
||||
askAgain: "Redemander",
|
||||
askAgainFor: "Redemander pour {{label}}",
|
||||
unavailable: "Absent des hôtes connectés",
|
||||
empty: "Les panneaux de fichier apparaissent ici quand un plugin en ajoute un",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
permission: "Autorisation des notifications",
|
||||
|
||||
@@ -1937,12 +1937,32 @@ export const ja: TranslationResources = {
|
||||
creatingAgent: "エージェントを作成中",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: ".{{extension}} ファイルを開くアプリ",
|
||||
pickerTitleFilename: "{{filename}} を開くアプリ",
|
||||
dontAskAgain: "今後は確認しない",
|
||||
use: "使用",
|
||||
openWith: "このアプリで開く…",
|
||||
alwaysUseExtension: ".{{extension}} に常に使用",
|
||||
alwaysUseFilename: "{{filename}} に常に使用",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "ファイルの操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "画像",
|
||||
source: "ソース",
|
||||
},
|
||||
},
|
||||
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
||||
loading: "ファイルを読み込み中...",
|
||||
noPreview: "プレビューが利用できません",
|
||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||
documentUnsupported: "{{type}} ファイルはこのデバイスでは表示できません",
|
||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||
notFound: "ファイルが見つかりません",
|
||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||
editor: {
|
||||
fileSize: "ファイルサイズ {{size}}",
|
||||
@@ -1954,10 +1974,7 @@ export const ja: TranslationResources = {
|
||||
changedOnDisk: "ディスク上で変更されました",
|
||||
vimMode: "Vim モード {{mode}}",
|
||||
cursor: "{{line}} 行、{{column}} 列",
|
||||
preview: "プレビュー",
|
||||
source: "ソース",
|
||||
deletedTitle: "ファイルはディスク上から削除されました",
|
||||
checkFailedTitle: "ディスク上のファイルを確認できませんでした",
|
||||
preservedDescription: "開いているコピーは保持されています。",
|
||||
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
|
||||
overwrite: "上書き",
|
||||
@@ -2159,6 +2176,7 @@ export const ja: TranslationResources = {
|
||||
terminal: "ターミナル",
|
||||
browser: "ブラウザ",
|
||||
editor: "エディター",
|
||||
files: "ファイル",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
notifications: "通知",
|
||||
@@ -2172,6 +2190,14 @@ export const ja: TranslationResources = {
|
||||
vimKeybindings: "Vim キーバインド",
|
||||
vimHint: "Web とデスクトップのソースファイルに適用されます。",
|
||||
},
|
||||
files: {
|
||||
title: "ファイル",
|
||||
askEveryTime: "毎回確認する",
|
||||
askAgain: "もう一度確認する",
|
||||
askAgainFor: "{{label}} をもう一度確認する",
|
||||
unavailable: "接続中のホストにありません",
|
||||
empty: "プラグインがファイルパネルを追加すると、ここに表示されます",
|
||||
},
|
||||
notifications: {
|
||||
title: "通知",
|
||||
permission: "通知の権限",
|
||||
|
||||
@@ -1928,12 +1928,32 @@ export const ko: TranslationResources = {
|
||||
creatingAgent: "에이전트 생성 중",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: ".{{extension}} 파일 열기 방식",
|
||||
pickerTitleFilename: "{{filename}} 열기 방식",
|
||||
dontAskAgain: "다시 묻지 않기",
|
||||
use: "사용",
|
||||
openWith: "다음으로 열기…",
|
||||
alwaysUseExtension: ".{{extension}}에 항상 사용",
|
||||
alwaysUseFilename: "{{filename}}에 항상 사용",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "파일 작업",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "이미지",
|
||||
source: "소스",
|
||||
},
|
||||
},
|
||||
directoryMissing: "워크스페이스 디렉터리를 찾을 수 없습니다.",
|
||||
loading: "파일 불러오는 중...",
|
||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||
documentUnsupported: "{{type}} 파일은 이 기기에서 표시할 수 없습니다",
|
||||
failedToLoad: "파일을 불러오지 못했습니다",
|
||||
notFound: "파일을 찾을 수 없습니다",
|
||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||
editor: {
|
||||
fileSize: "파일 크기 {{size}}",
|
||||
@@ -1945,10 +1965,7 @@ export const ko: TranslationResources = {
|
||||
changedOnDisk: "디스크에서 변경됨",
|
||||
vimMode: "Vim 모드 {{mode}}",
|
||||
cursor: "{{line}} 라인, {{column}} 열",
|
||||
preview: "미리보기",
|
||||
source: "소스",
|
||||
deletedTitle: "디스크에서 파일이 삭제되었습니다.",
|
||||
checkFailedTitle: "디스크의 파일을 확인할 수 없습니다.",
|
||||
preservedDescription: "열린 복사본은 보존됩니다.",
|
||||
conflictDescription: "로컬 버퍼가 보존되었습니다. 유지할 버전을 선택하세요.",
|
||||
overwrite: "덮어쓰기",
|
||||
@@ -2148,6 +2165,7 @@ export const ko: TranslationResources = {
|
||||
terminal: "터미널",
|
||||
browser: "브라우저",
|
||||
editor: "편집기",
|
||||
files: "파일",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
notifications: "알림",
|
||||
@@ -2161,6 +2179,14 @@ export const ko: TranslationResources = {
|
||||
vimKeybindings: "Vim 키 바인딩",
|
||||
vimHint: "웹 및 데스크톱의 소스 파일에 적용됩니다.",
|
||||
},
|
||||
files: {
|
||||
title: "파일",
|
||||
askEveryTime: "매번 묻기",
|
||||
askAgain: "다시 묻기",
|
||||
askAgainFor: "{{label}} 다시 묻기",
|
||||
unavailable: "연결된 호스트에 없음",
|
||||
empty: "플러그인이 파일 패널을 추가하면 여기에 표시됩니다",
|
||||
},
|
||||
notifications: {
|
||||
title: "알림",
|
||||
permission: "알림 권한",
|
||||
|
||||
@@ -1951,12 +1951,32 @@ export const ptBR: TranslationResources = {
|
||||
creatingAgent: "Criando agente",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "Abrir arquivos .{{extension}} com",
|
||||
pickerTitleFilename: "Abrir {{filename}} com",
|
||||
dontAskAgain: "Não perguntar novamente",
|
||||
use: "Usar",
|
||||
openWith: "Abrir com…",
|
||||
alwaysUseExtension: "Sempre usar para .{{extension}}",
|
||||
alwaysUseFilename: "Sempre usar para {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Ações do arquivo",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "Imagem",
|
||||
source: "Fonte",
|
||||
},
|
||||
},
|
||||
directoryMissing: "Diretório do workspace não encontrado.",
|
||||
loading: "Carregando arquivo...",
|
||||
noPreview: "Nenhuma prévia disponível",
|
||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
|
||||
documentUnsupported: "Arquivos {{type}} não podem ser exibidos neste dispositivo",
|
||||
failedToLoad: "Falha ao carregar arquivo",
|
||||
notFound: "Arquivo não encontrado",
|
||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||
editor: {
|
||||
fileSize: "Tamanho {{size}}",
|
||||
@@ -1968,10 +1988,7 @@ export const ptBR: TranslationResources = {
|
||||
changedOnDisk: "Alterado no disco",
|
||||
vimMode: "Modo Vim {{mode}}",
|
||||
cursor: "Linha {{line}}, coluna {{column}}",
|
||||
preview: "Prévia",
|
||||
source: "Fonte",
|
||||
deletedTitle: "Arquivo excluído do disco",
|
||||
checkFailedTitle: "Não foi possível verificar o arquivo no disco",
|
||||
preservedDescription: "A cópia aberta foi preservada.",
|
||||
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
|
||||
overwrite: "Sobrescrever",
|
||||
@@ -2171,6 +2188,7 @@ export const ptBR: TranslationResources = {
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
files: "Arquivos",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
notifications: "Notificações",
|
||||
@@ -2184,6 +2202,14 @@ export const ptBR: TranslationResources = {
|
||||
vimKeybindings: "Atalhos do Vim",
|
||||
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
|
||||
},
|
||||
files: {
|
||||
title: "Arquivos",
|
||||
askEveryTime: "Perguntar sempre",
|
||||
askAgain: "Perguntar de novo",
|
||||
askAgainFor: "Perguntar de novo para {{label}}",
|
||||
unavailable: "Não está em um host conectado",
|
||||
empty: "Os painéis de arquivo aparecem aqui quando um plugin adiciona um",
|
||||
},
|
||||
notifications: {
|
||||
title: "Notificações",
|
||||
permission: "Permissão de notificações",
|
||||
|
||||
@@ -1950,12 +1950,32 @@ export const ru: TranslationResources = {
|
||||
creatingAgent: "Создание агента",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "Открывать файлы .{{extension}} с помощью",
|
||||
pickerTitleFilename: "Открывать {{filename}} с помощью",
|
||||
dontAskAgain: "Больше не спрашивать",
|
||||
use: "Использовать",
|
||||
openWith: "Открыть с помощью…",
|
||||
alwaysUseExtension: "Всегда использовать для .{{extension}}",
|
||||
alwaysUseFilename: "Всегда использовать для {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "Действия с файлом",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "Изображение",
|
||||
source: "Исходный текст",
|
||||
},
|
||||
},
|
||||
directoryMissing: "Каталог рабочего пространства не найден.",
|
||||
loading: "Загрузка файла...",
|
||||
noPreview: "Предварительный просмотр недоступен",
|
||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||
tooLargeToDisplay: "Этот файл слишком велик для отображения",
|
||||
documentUnsupported: "Файлы {{type}} нельзя показать на этом устройстве",
|
||||
failedToLoad: "Не удалось загрузить файл",
|
||||
notFound: "Файл не найден",
|
||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||
editor: {
|
||||
fileSize: "Размер файла {{size}}",
|
||||
@@ -1967,10 +1987,7 @@ export const ru: TranslationResources = {
|
||||
changedOnDisk: "Изменён на диске",
|
||||
vimMode: "Режим Vim {{mode}}",
|
||||
cursor: "Строка {{line}}, столбец {{column}}",
|
||||
preview: "Предпросмотр",
|
||||
source: "Исходный текст",
|
||||
deletedTitle: "Файл удалён с диска",
|
||||
checkFailedTitle: "Не удалось проверить файл на диске",
|
||||
preservedDescription: "Открытая копия сохранена в редакторе.",
|
||||
conflictDescription:
|
||||
"Локальный буфер сохранён в редакторе. Выберите, какую версию оставить.",
|
||||
@@ -2171,6 +2188,7 @@ export const ru: TranslationResources = {
|
||||
terminal: "Терминал",
|
||||
browser: "Браузер",
|
||||
editor: "Редактор",
|
||||
files: "Файлы",
|
||||
shortcuts: "Сочетания клавиш",
|
||||
integrations: "Интеграции",
|
||||
notifications: "Уведомления",
|
||||
@@ -2184,6 +2202,14 @@ export const ru: TranslationResources = {
|
||||
vimKeybindings: "Сочетания клавиш Vim",
|
||||
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
|
||||
},
|
||||
files: {
|
||||
title: "Файлы",
|
||||
askEveryTime: "Спрашивать каждый раз",
|
||||
askAgain: "Спросить снова",
|
||||
askAgainFor: "Спросить снова для {{label}}",
|
||||
unavailable: "Нет на подключённых хостах",
|
||||
empty: "Панели файлов появятся здесь, когда плагин добавит их",
|
||||
},
|
||||
notifications: {
|
||||
title: "Уведомления",
|
||||
permission: "Разрешение на уведомления",
|
||||
|
||||
@@ -1896,12 +1896,32 @@ export const zhCN: TranslationResources = {
|
||||
creatingAgent: "正在创建 Agent",
|
||||
},
|
||||
file: {
|
||||
filePanels: {
|
||||
pickerTitleExtension: "打开 .{{extension}} 文件的方式",
|
||||
pickerTitleFilename: "打开 {{filename}} 的方式",
|
||||
dontAskAgain: "不再询问",
|
||||
use: "使用",
|
||||
openWith: "打开方式…",
|
||||
alwaysUseExtension: "始终用于 .{{extension}}",
|
||||
alwaysUseFilename: "始终用于 {{filename}}",
|
||||
option: "{{title}} — {{owner}}",
|
||||
actions: "文件操作",
|
||||
builtin: {
|
||||
owner: "Paseo",
|
||||
markdown: "Markdown",
|
||||
html: "HTML",
|
||||
image: "图片",
|
||||
source: "源代码",
|
||||
},
|
||||
},
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
loading: "正在加载文件...",
|
||||
noPreview: "没有可用预览",
|
||||
binaryPreviewUnavailable: "二进制预览不可用",
|
||||
tooLargeToDisplay: "此文件过大,无法显示",
|
||||
documentUnsupported: "无法在此设备上显示 {{type}} 文件",
|
||||
failedToLoad: "加载文件失败",
|
||||
notFound: "找不到文件",
|
||||
failedToLoadPreview: "加载文件预览失败",
|
||||
editor: {
|
||||
fileSize: "文件大小 {{size}}",
|
||||
@@ -1913,10 +1933,7 @@ export const zhCN: TranslationResources = {
|
||||
changedOnDisk: "磁盘上的文件已更改",
|
||||
vimMode: "Vim 模式 {{mode}}",
|
||||
cursor: "第 {{line}} 行,第 {{column}} 列",
|
||||
preview: "预览",
|
||||
source: "源代码",
|
||||
deletedTitle: "文件已从磁盘删除",
|
||||
checkFailedTitle: "无法检查磁盘上的文件",
|
||||
preservedDescription: "打开的副本已保留。",
|
||||
conflictDescription: "本地内容已保留。请选择要保留的版本。",
|
||||
overwrite: "覆盖",
|
||||
@@ -2116,6 +2133,7 @@ export const zhCN: TranslationResources = {
|
||||
terminal: "终端",
|
||||
browser: "浏览器",
|
||||
editor: "编辑器",
|
||||
files: "文件",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
notifications: "通知",
|
||||
@@ -2129,6 +2147,14 @@ export const zhCN: TranslationResources = {
|
||||
vimKeybindings: "Vim 键位",
|
||||
vimHint: "适用于网页和桌面端的源文件。",
|
||||
},
|
||||
files: {
|
||||
title: "文件",
|
||||
askEveryTime: "每次询问",
|
||||
askAgain: "重新询问",
|
||||
askAgainFor: "重新询问 {{label}}",
|
||||
unavailable: "不在已连接的主机上",
|
||||
empty: "插件添加文件面板后会显示在这里",
|
||||
},
|
||||
notifications: {
|
||||
title: "通知",
|
||||
permission: "通知权限",
|
||||
|
||||
@@ -18,6 +18,7 @@ function installation(): InstalledPlugin {
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -78,6 +78,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
surfaces: [{ id: "main", title: "Main", Component: () => null }],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -695,9 +695,10 @@ describe("evaluatePluginClientBundle", () => {
|
||||
`(function(require) {
|
||||
const shared = require("@getpaseo/plugin");
|
||||
const client = require("@getpaseo/plugin/client");
|
||||
const { ExternalLink } = require("@getpaseo/plugin/client/ui");
|
||||
const { ExternalLink, Loading } = require("@getpaseo/plugin/client/ui");
|
||||
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
|
||||
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl"]) {
|
||||
if (typeof Loading !== "function") throw new Error("Loading");
|
||||
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError", "PluginFileDeletedError"]) {
|
||||
if (name in shared || typeof client[name] !== "function") throw new Error(name);
|
||||
}
|
||||
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
|
||||
@@ -812,3 +813,49 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
await first.cleanup();
|
||||
await second.cleanup();
|
||||
});
|
||||
|
||||
describe("file panels", () => {
|
||||
const panel =
|
||||
'{ id: "pdf", title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, Component() { return null; } }';
|
||||
it("registers a panel and removes it on cleanup", async () => {
|
||||
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${panel})`));
|
||||
expect(plugin.filePanels.map(({ id, title, files }) => ({ id, title, files }))).toEqual([
|
||||
{ id: "pdf", title: "PDF", files: { extensions: ["pdf"] } },
|
||||
]);
|
||||
await plugin.cleanup();
|
||||
expect(plugin.filePanels).toEqual([]);
|
||||
});
|
||||
it.each([
|
||||
['files: { extensions: ["pdf"] }', 'files: { pattern: "*.pdf" }', "selector"],
|
||||
['files: { extensions: ["pdf"] }', 'files: { extensions: [".pdf"] }', "selector"],
|
||||
['files: { extensions: ["pdf"] }', 'files: { extensions: ["*"] }', "selector"],
|
||||
['files: { extensions: ["pdf"] }', 'files: { extensions: ["foo/bar"] }', "selector"],
|
||||
['files: { extensions: ["pdf"] }', "files: { extensions: [] }", "selector"],
|
||||
['files: { extensions: ["pdf"] }', 'files: { extensions: [], filenames: ["a/b"] }', "selector"],
|
||||
['id: "pdf"', 'id: "Bad"', "file panel id"],
|
||||
['title: "PDF"', 'title: " "', "title"],
|
||||
["Component() { return null; }", "Component: {}", "component"],
|
||||
['icon: "FileText"', 'icon: ""', "icon"],
|
||||
])("rejects invalid registration %s → %s", (from, to, error) => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"files",
|
||||
bundle(`plugin.addFilePanel(${panel.replace(from, to)})`),
|
||||
),
|
||||
).toThrow(error);
|
||||
});
|
||||
it("accepts filename selectors and rejects duplicate IDs", () => {
|
||||
const source = panel.replace(
|
||||
'extensions: ["pdf"]',
|
||||
'extensions: [], filenames: ["Dockerfile"]',
|
||||
);
|
||||
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${source})`));
|
||||
expect(plugin.filePanels[0]?.files).toEqual({ extensions: [], filenames: ["Dockerfile"] });
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"files",
|
||||
bundle(`plugin.addFilePanel(${source}); plugin.addFilePanel(${source})`),
|
||||
),
|
||||
).toThrow("Duplicate file panel");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ export function runPluginClientBundle(
|
||||
settingsScreens: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -127,6 +128,7 @@ export function runPluginClientBundle(
|
||||
header: new Set(),
|
||||
footer: new Set(),
|
||||
};
|
||||
const filePanelIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -254,6 +256,50 @@ export function runPluginClientBundle(
|
||||
ids.add(normalizedId);
|
||||
return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId));
|
||||
},
|
||||
addFilePanel(contribution) {
|
||||
const panelId = requireId(contribution.id, "file panel id");
|
||||
if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`);
|
||||
const title = contribution.title.trim();
|
||||
if (!title) throw new Error(`File panel ${panelId} has no title`);
|
||||
const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon);
|
||||
const files = contribution.files;
|
||||
const validEntries = (entries: unknown, extension: boolean): entries is string[] =>
|
||||
Array.isArray(entries) &&
|
||||
entries.every(
|
||||
(entry) =>
|
||||
typeof entry === "string" &&
|
||||
entry.trim() === entry &&
|
||||
entry.length > 0 &&
|
||||
!/[\\/*?]/.test(entry) &&
|
||||
(!extension || !entry.includes(".")),
|
||||
);
|
||||
if (
|
||||
!files ||
|
||||
Object.keys(files).some((key) => key !== "extensions" && key !== "filenames") ||
|
||||
!validEntries(files.extensions, true) ||
|
||||
(files.filenames !== undefined && !validEntries(files.filenames, false)) ||
|
||||
(files.extensions.length === 0 && !files.filenames?.length)
|
||||
) {
|
||||
throw new Error(`File panel ${panelId} has invalid selector`);
|
||||
}
|
||||
if (typeof contribution.Component !== "function")
|
||||
throw new Error(`File panel ${panelId} is not a component`);
|
||||
filePanelIds.add(panelId);
|
||||
return register(
|
||||
collector.filePanels,
|
||||
{
|
||||
...contribution,
|
||||
id: panelId,
|
||||
title,
|
||||
icon,
|
||||
files: {
|
||||
extensions: [...files.extensions],
|
||||
...(files.filenames ? { filenames: [...files.filenames] } : {}),
|
||||
},
|
||||
},
|
||||
() => filePanelIds.delete(panelId),
|
||||
);
|
||||
},
|
||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
|
||||
const normalizedId = requireId(contribution.id, "workspace panel id");
|
||||
if (workspacePanelIds.has(normalizedId)) {
|
||||
@@ -510,6 +556,7 @@ export function runPluginClientBundle(
|
||||
settingsScreens: collector.settingsScreens,
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
|
||||
@@ -32,6 +32,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -12,3 +12,7 @@ export {
|
||||
export { ExternalLink } from "@/components/ui/external-link";
|
||||
|
||||
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
|
||||
|
||||
export { DocumentFrame } from "@/file-panels/document-frame";
|
||||
|
||||
export { FilePanelLoading as Loading } from "@/file-panels/loading";
|
||||
|
||||
@@ -99,3 +99,23 @@ it("unloads on a requirement-only edit and recovers after correction", () => {
|
||||
expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]);
|
||||
expect(result.getEvaluationError("host", "example")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips file-panel bundles on older apps and installs them on the required app", () => {
|
||||
const entry = {
|
||||
id: "files",
|
||||
requirements: { paseo: ">=0.13.0" },
|
||||
clientBundle: `(function() { return { default(client) {
|
||||
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
|
||||
files: { extensions: ["pdf"] }, document: "bytes", Component() { return null; } });
|
||||
return () => {};
|
||||
} }; })`,
|
||||
};
|
||||
const old = registry("0.12.0");
|
||||
old.result.installCatalog("host", [entry], { client, audio });
|
||||
expect(old.starts()).toBe(0);
|
||||
expect(old.result.getSnapshot()).toEqual([]);
|
||||
const current = registry("0.13.0");
|
||||
current.result.installCatalog("host", [entry], { client, audio });
|
||||
expect(current.starts()).toBe(1);
|
||||
expect(current.result.getSnapshot()[0]?.filePanels.map((panel) => panel.id)).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ export class PluginRegistry {
|
||||
settingsScreens: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -30,6 +30,7 @@ function installed(
|
||||
footer: [{ id: "status", title: "Status", Component: () => null }],
|
||||
},
|
||||
legacySidebarItems,
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -32,6 +32,7 @@ function installation(
|
||||
title: item.id,
|
||||
icon: "Blocks",
|
||||
})),
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -57,6 +57,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -20,6 +20,7 @@ function plugin(input: {
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PluginThemeContribution,
|
||||
} from "@getpaseo/plugin";
|
||||
import type {
|
||||
PluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
@@ -35,6 +36,7 @@ export interface EvaluatedPlugin {
|
||||
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
|
||||
@@ -17,6 +17,7 @@ function installed(): InstalledPlugin {
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FolderGit2,
|
||||
SquareTerminal,
|
||||
Code2,
|
||||
Files,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Blocks,
|
||||
@@ -78,6 +79,7 @@ import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||
import { EditorSection } from "@/screens/settings/editor-section";
|
||||
import { FilesSection } from "@/screens/settings/files-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { CommunityLinks } from "@/components/community-links";
|
||||
@@ -185,6 +187,7 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
webOnly: true,
|
||||
Content: EditorSection,
|
||||
},
|
||||
{ id: "files", labelKey: "settings.sections.files", icon: Files, Content: FilesSection },
|
||||
{
|
||||
id: "shortcuts",
|
||||
labelKey: "settings.sections.shortcuts",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import {
|
||||
filePanelIcon,
|
||||
filePanelOptionLabel,
|
||||
filePanelSelectorLabel,
|
||||
useFilePanelSettings,
|
||||
type FilePanel,
|
||||
type FilePanelSettingsRow,
|
||||
} from "@/file-panels";
|
||||
import { PluginIconView } from "@/plugins/icons";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const MENU_ICON_SIZE = 16;
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PluginIconView);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** Which panel opens each file type, for the types more than one panel claims. */
|
||||
export function FilesSection() {
|
||||
const { t } = useTranslation();
|
||||
const { rows, choose, askAgain } = useFilePanelSettings();
|
||||
return (
|
||||
<SettingsSection title={t("settings.files.title")} testID="file-panel-settings">
|
||||
{rows.length === 0 ? (
|
||||
<View style={settingsStyles.card} testID="file-panel-settings-empty">
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>{t("settings.files.empty")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<SettingsCard testID="file-panel-settings-card">
|
||||
{rows.map((row) => (
|
||||
<FilePanelSettingsRowView
|
||||
key={row.selectorKey}
|
||||
row={row}
|
||||
onChoose={choose}
|
||||
onAskAgain={askAgain}
|
||||
/>
|
||||
))}
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePanelSettingsRowView({
|
||||
row,
|
||||
onChoose,
|
||||
onAskAgain,
|
||||
}: {
|
||||
row: FilePanelSettingsRow<FilePanel>;
|
||||
onChoose(selectorKey: string, panelId: string): void;
|
||||
onAskAgain(selectorKey: string): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { selectorKey, associatedPanelId } = row;
|
||||
const label = filePanelSelectorLabel(selectorKey);
|
||||
const value = associatedValueLabel(t, row);
|
||||
const choose = useCallback(
|
||||
(panelId: string) => onChoose(selectorKey, panelId),
|
||||
[onChoose, selectorKey],
|
||||
);
|
||||
const askAgain = useCallback(() => onAskAgain(selectorKey), [onAskAgain, selectorKey]);
|
||||
return (
|
||||
<SettingsRow label={label} testID={`file-panel-settings-row-${selectorKey}`}>
|
||||
<View style={styles.controls}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={row.candidates.length === 0}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${label}: ${value}`}
|
||||
>
|
||||
{value}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
minWidth={260}
|
||||
maxWidth={360}
|
||||
testID="file-panel-settings-menu"
|
||||
>
|
||||
{row.candidates.map((panel) => (
|
||||
<FilePanelOption
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
selected={panel.id === associatedPanelId}
|
||||
onSelect={choose}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={askAgain}
|
||||
disabled={associatedPanelId === null}
|
||||
accessibilityLabel={t("settings.files.askAgainFor", { label })}
|
||||
>
|
||||
{t("settings.files.askAgain")}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the dropdown shows: the remembered panel, or that Paseo asks on every open. */
|
||||
function associatedValueLabel(t: TFunction, row: FilePanelSettingsRow<FilePanel>): string {
|
||||
if (row.associatedPanelId === null) return t("settings.files.askEveryTime");
|
||||
const panel = row.candidates.find((candidate) => candidate.id === row.associatedPanelId);
|
||||
return panel ? filePanelOptionLabel(t, panel) : t("settings.files.unavailable");
|
||||
}
|
||||
|
||||
function FilePanelOption({
|
||||
panel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
panel: FilePanel;
|
||||
selected: boolean;
|
||||
onSelect(panelId: string): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const select = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ThemedPanelIcon
|
||||
icon={filePanelIcon(panel)}
|
||||
size={MENU_ICON_SIZE}
|
||||
uniProps={mutedIconMapping}
|
||||
/>
|
||||
),
|
||||
[panel],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} leading={leading} onSelect={select}>
|
||||
{filePanelOptionLabel(t, panel)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
controls: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
}));
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WorkspaceTabOptionRow,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { useFilePanelOpenWithMenu } from "@/file-panels";
|
||||
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
|
||||
import {
|
||||
buildWorkspaceTabMenuEntries,
|
||||
@@ -235,15 +236,20 @@ function MobileWorkspaceTabOption({
|
||||
[t],
|
||||
);
|
||||
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
|
||||
const openWith = useFilePanelOpenWithMenu(
|
||||
{ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: tab.tabId },
|
||||
menuTestIDBase,
|
||||
);
|
||||
const trailingAccessory = useMemo(
|
||||
() => (
|
||||
<MobileTabTrailingAccessory
|
||||
menuTestIDBase={menuTestIDBase}
|
||||
presentationLabel={fallbackLabel}
|
||||
menuEntries={menuEntries}
|
||||
openWith={openWith}
|
||||
/>
|
||||
),
|
||||
[menuTestIDBase, fallbackLabel, menuEntries],
|
||||
[menuTestIDBase, fallbackLabel, menuEntries, openWith],
|
||||
);
|
||||
|
||||
const renderPresentation = useCallback(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -10,6 +11,7 @@ import React, {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { useFilePanelOpenWithMenu } from "@/file-panels";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
@@ -720,6 +722,7 @@ function TabHandleContent({
|
||||
|
||||
function TabChip({
|
||||
serverId,
|
||||
workspaceId,
|
||||
tab,
|
||||
isActive,
|
||||
isDragging,
|
||||
@@ -739,6 +742,7 @@ function TabChip({
|
||||
dragHandleProps,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
isActive: boolean;
|
||||
isDragging: boolean;
|
||||
@@ -759,6 +763,10 @@ function TabChip({
|
||||
}) {
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
const { t } = useTranslation();
|
||||
const openWith = useFilePanelOpenWithMenu(
|
||||
{ serverId, workspaceId, tabId: tab.tabId },
|
||||
contextMenuTestId,
|
||||
);
|
||||
const middleClickRef = useMiddleClickClose(
|
||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||
);
|
||||
@@ -936,14 +944,19 @@ function TabChip({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<TabContextMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={DROPDOWN_WIDTH}
|
||||
pages={openWith.pages}
|
||||
testID={contextMenuTestId}
|
||||
>
|
||||
{menuEntries.map((entry) => {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator key={entry.key} />;
|
||||
if (entry.kind === "file-panel-open-with") {
|
||||
return <Fragment key={entry.key}>{openWith.trigger}</Fragment>;
|
||||
}
|
||||
return <TabContextMenuItem key={entry.key} entry={entry} />;
|
||||
})}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</View>
|
||||
@@ -1275,6 +1288,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
<ResolvedDesktopTabChip
|
||||
key={`${item.tab.key}:${item.tab.kind}`}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
item={item}
|
||||
isFocused={isFocused}
|
||||
isDragging={isActive}
|
||||
@@ -1308,6 +1322,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
layout.closeButtonPolicy,
|
||||
layout.items,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
onCloseOtherTabs,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
@@ -1428,6 +1443,7 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
}
|
||||
function ResolvedDesktopTabChip({
|
||||
serverId,
|
||||
workspaceId,
|
||||
item,
|
||||
isFocused,
|
||||
isDragging,
|
||||
@@ -1454,6 +1470,7 @@ function ResolvedDesktopTabChip({
|
||||
showDropIndicatorAfter,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
item: ResolvedWorkspaceDesktopTabRowItem;
|
||||
isFocused: boolean;
|
||||
isDragging: boolean;
|
||||
@@ -1535,6 +1552,7 @@ function ResolvedDesktopTabChip({
|
||||
) : null}
|
||||
<TabChip
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
|
||||
@@ -272,6 +272,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
}
|
||||
copyFilePathEntry.onSelect();
|
||||
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
|
||||
expect(entries[1]).toEqual({ kind: "file-panel-open-with", key: "file-panel-open-with" });
|
||||
});
|
||||
|
||||
it("uses a Changes close id for the working diff tab", () => {
|
||||
|
||||
@@ -60,6 +60,11 @@ export type WorkspaceTabMenuEntry =
|
||||
| {
|
||||
kind: "separator";
|
||||
key: string;
|
||||
}
|
||||
| {
|
||||
/** Where the file tab's "Open with…" row goes; it renders only for files with several panels. */
|
||||
kind: "file-panel-open-with";
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface BuildWorkspaceTabMenuEntriesInput {
|
||||
@@ -241,6 +246,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyFilePath(filePath);
|
||||
},
|
||||
});
|
||||
entries.push({ kind: "file-panel-open-with", key: "file-panel-open-with" });
|
||||
}
|
||||
|
||||
if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { Fragment, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { FilePanelOpenWithMenu } from "@/file-panels";
|
||||
import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -88,10 +89,13 @@ export function MobileTabTrailingAccessory({
|
||||
menuTestIDBase,
|
||||
presentationLabel,
|
||||
menuEntries,
|
||||
openWith,
|
||||
}: {
|
||||
menuTestIDBase: string;
|
||||
presentationLabel: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
/** A file tab's "Open with…" row and its page. */
|
||||
openWith?: FilePanelOpenWithMenu;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -110,15 +114,16 @@ export function MobileTabTrailingAccessory({
|
||||
align="end"
|
||||
width={220}
|
||||
sheetTitle={presentationLabel}
|
||||
pages={openWith?.pages}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||
),
|
||||
)}
|
||||
{menuEntries.map((entry) => {
|
||||
if (entry.kind === "separator") return <DropdownMenuSeparator key={entry.key} />;
|
||||
if (entry.kind === "file-panel-open-with") {
|
||||
return <Fragment key={entry.key}>{openWith?.trigger}</Fragment>;
|
||||
}
|
||||
return <MobileTabDropdownMenuItem key={entry.key} entry={entry} />;
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user