feat: add Composer input history navigation
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,12 +37,21 @@
|
|||||||
type ComposerPaste,
|
type ComposerPaste,
|
||||||
type ComposerTextPaste,
|
type ComposerTextPaste,
|
||||||
} from "$lib/workspace/console/composer-draft.ts";
|
} from "$lib/workspace/console/composer-draft.ts";
|
||||||
|
import {
|
||||||
|
ComposerHistory,
|
||||||
|
loadComposerHistory,
|
||||||
|
saveComposerHistory,
|
||||||
|
shouldBrowseComposerHistory,
|
||||||
|
type ComposerHistoryDirection,
|
||||||
|
type ComposerHistoryEntry,
|
||||||
|
} from "$lib/workspace/console/composer-history.ts";
|
||||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
ariaKeyShortcuts?: string;
|
ariaKeyShortcuts?: string;
|
||||||
|
historyScope: string;
|
||||||
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
onchange?: (snapshot: ComposerDraftSnapshot) => void;
|
||||||
onkeydown?: (event: KeyboardEvent) => void;
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
onsubmit?: () => void;
|
onsubmit?: () => void;
|
||||||
@@ -52,6 +61,7 @@
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
ariaLabel = "Message",
|
ariaLabel = "Message",
|
||||||
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
||||||
|
historyScope,
|
||||||
onchange,
|
onchange,
|
||||||
onkeydown,
|
onkeydown,
|
||||||
onsubmit,
|
onsubmit,
|
||||||
@@ -59,6 +69,8 @@
|
|||||||
|
|
||||||
let mountElement: HTMLDivElement;
|
let mountElement: HTMLDivElement;
|
||||||
let view: EditorView | null = null;
|
let view: EditorView | null = null;
|
||||||
|
let composerHistory = new ComposerHistory();
|
||||||
|
let restoringHistory = false;
|
||||||
let nextPasteId = 1;
|
let nextPasteId = 1;
|
||||||
let nextPasteKey = 1;
|
let nextPasteKey = 1;
|
||||||
const editable = new Compartment();
|
const editable = new Compartment();
|
||||||
@@ -179,6 +191,40 @@
|
|||||||
onchange?.(currentSnapshot());
|
onchange?.(currentSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function historyEntry(state: EditorState): ComposerHistoryEntry {
|
||||||
|
const snapshot = currentSnapshot(state);
|
||||||
|
return {
|
||||||
|
segments: snapshot.segments,
|
||||||
|
preserveExactText: snapshot.textPastes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
|
||||||
|
const selection = currentView.state.selection.main;
|
||||||
|
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
|
||||||
|
if (!shouldBrowseComposerHistory({
|
||||||
|
direction,
|
||||||
|
cursorLine,
|
||||||
|
lineCount: currentView.state.doc.lines,
|
||||||
|
selectionEmpty: selection.empty,
|
||||||
|
readOnly: currentView.state.readOnly,
|
||||||
|
composing: currentView.composing,
|
||||||
|
})) return false;
|
||||||
|
|
||||||
|
const entry = direction === "older"
|
||||||
|
? composerHistory.previous(historyEntry(currentView.state))
|
||||||
|
: composerHistory.next();
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
restoringHistory = true;
|
||||||
|
try {
|
||||||
|
restoreSegments(entry.segments, entry.preserveExactText);
|
||||||
|
} finally {
|
||||||
|
restoringHistory = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
|
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
const selection = view.state.selection.main;
|
const selection = view.state.selection.main;
|
||||||
@@ -273,6 +319,10 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
composerHistory = loadComposerHistory(localStorage, historyScope);
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
view = new EditorView({
|
view = new EditorView({
|
||||||
parent: mountElement,
|
parent: mountElement,
|
||||||
@@ -292,6 +342,14 @@
|
|||||||
key: "Mod-y",
|
key: "Mod-y",
|
||||||
run: (currentView) => currentView.state.readOnly,
|
run: (currentView) => currentView.state.readOnly,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "ArrowUp",
|
||||||
|
run: (currentView) => browseHistory(currentView, "older"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ArrowDown",
|
||||||
|
run: (currentView) => browseHistory(currentView, "newer"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "Backspace",
|
key: "Backspace",
|
||||||
run: (currentView) =>
|
run: (currentView) =>
|
||||||
@@ -319,6 +377,7 @@
|
|||||||
spellcheck: "true",
|
spellcheck: "true",
|
||||||
}),
|
}),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
|
||||||
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
||||||
emitChange();
|
emitChange();
|
||||||
}
|
}
|
||||||
@@ -396,6 +455,16 @@
|
|||||||
return currentSnapshot();
|
return currentSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordHistory(value: ComposerDraftSnapshot): void {
|
||||||
|
const entry: ComposerHistoryEntry = {
|
||||||
|
segments: value.segments,
|
||||||
|
preserveExactText: value.textPastes.length > 0,
|
||||||
|
};
|
||||||
|
if (composerHistory.record(entry)) {
|
||||||
|
saveComposerHistory(localStorage, historyScope, composerHistory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function focus(): void {
|
export function focus(): void {
|
||||||
view?.focus();
|
view?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { Segment } from "$lib/generated/protocol";
|
||||||
|
|
||||||
|
export const COMPOSER_HISTORY_LIMIT = 30;
|
||||||
|
const COMPOSER_HISTORY_VERSION = 1;
|
||||||
|
const COMPOSER_HISTORY_KEY_PREFIX = "yoi.composer-history.v1.workspace.";
|
||||||
|
|
||||||
|
export type ComposerHistoryDirection = "older" | "newer";
|
||||||
|
|
||||||
|
export type ComposerHistoryCursor = {
|
||||||
|
direction: ComposerHistoryDirection;
|
||||||
|
cursorLine: number;
|
||||||
|
lineCount: number;
|
||||||
|
selectionEmpty: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
composing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComposerHistoryEntry = {
|
||||||
|
segments: Segment[];
|
||||||
|
preserveExactText: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredComposerHistory = {
|
||||||
|
version: typeof COMPOSER_HISTORY_VERSION;
|
||||||
|
entries: ComposerHistoryEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComposerHistoryStorage = Pick<Storage, "getItem" | "setItem">;
|
||||||
|
|
||||||
|
function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry {
|
||||||
|
return {
|
||||||
|
segments: entry.segments.map((segment) => ({ ...segment })) as Segment[],
|
||||||
|
preserveExactText: entry.preserveExactText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSegment(value: unknown): value is Segment {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const segment = value as Record<string, unknown>;
|
||||||
|
if (segment.kind === "text") return typeof segment.content === "string";
|
||||||
|
if (segment.kind === "paste") {
|
||||||
|
return typeof segment.content === "string" &&
|
||||||
|
typeof segment.id === "number" &&
|
||||||
|
typeof segment.chars === "number" &&
|
||||||
|
typeof segment.lines === "number";
|
||||||
|
}
|
||||||
|
if (segment.kind === "file_ref") return typeof segment.path === "string";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHistoryEntry(value: unknown): value is ComposerHistoryEntry {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as Record<string, unknown>;
|
||||||
|
return Array.isArray(entry.segments) &&
|
||||||
|
entry.segments.every(isSegment) &&
|
||||||
|
typeof entry.preserveExactText === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlankEntry(entry: ComposerHistoryEntry): boolean {
|
||||||
|
return entry.segments.length === 0 ||
|
||||||
|
entry.segments.every((segment) =>
|
||||||
|
segment.kind === "text" && segment.content.trim().length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEntry(
|
||||||
|
left: ComposerHistoryEntry,
|
||||||
|
right: ComposerHistoryEntry,
|
||||||
|
): boolean {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldBrowseComposerHistory(
|
||||||
|
cursor: ComposerHistoryCursor,
|
||||||
|
): boolean {
|
||||||
|
if (cursor.readOnly || cursor.composing || !cursor.selectionEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cursor.direction === "older"
|
||||||
|
? cursor.cursorLine === 1
|
||||||
|
: cursor.cursorLine === cursor.lineCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerHistoryStorageKey(workspaceId: string): string {
|
||||||
|
return `${COMPOSER_HISTORY_KEY_PREFIX}${encodeURIComponent(workspaceId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComposerHistory {
|
||||||
|
#entries: ComposerHistoryEntry[];
|
||||||
|
#index: number | null = null;
|
||||||
|
#draft: ComposerHistoryEntry | null = null;
|
||||||
|
|
||||||
|
constructor(entries: ComposerHistoryEntry[] = []) {
|
||||||
|
this.#entries = [];
|
||||||
|
for (const entry of entries) this.record(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
get entries(): ComposerHistoryEntry[] {
|
||||||
|
return this.#entries.map(cloneEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
get browsing(): boolean {
|
||||||
|
return this.#index !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
record(entry: ComposerHistoryEntry): boolean {
|
||||||
|
if (isBlankEntry(entry)) {
|
||||||
|
this.cancelNavigation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const last = this.#entries.at(-1);
|
||||||
|
if (last && sameEntry(last, entry)) {
|
||||||
|
this.cancelNavigation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#entries.push(cloneEntry(entry));
|
||||||
|
if (this.#entries.length > COMPOSER_HISTORY_LIMIT) {
|
||||||
|
this.#entries.splice(0, this.#entries.length - COMPOSER_HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
this.cancelNavigation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(draft: ComposerHistoryEntry): ComposerHistoryEntry | null {
|
||||||
|
if (this.#entries.length === 0) return null;
|
||||||
|
if (this.#index === null) {
|
||||||
|
this.#draft = cloneEntry(draft);
|
||||||
|
this.#index = this.#entries.length - 1;
|
||||||
|
} else if (this.#index > 0) {
|
||||||
|
this.#index -= 1;
|
||||||
|
}
|
||||||
|
return cloneEntry(this.#entries[this.#index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): ComposerHistoryEntry | null {
|
||||||
|
if (this.#index === null) return null;
|
||||||
|
if (this.#index < this.#entries.length - 1) {
|
||||||
|
this.#index += 1;
|
||||||
|
return cloneEntry(this.#entries[this.#index]);
|
||||||
|
}
|
||||||
|
const draft = this.#draft
|
||||||
|
? cloneEntry(this.#draft)
|
||||||
|
: { segments: [], preserveExactText: false };
|
||||||
|
this.cancelNavigation();
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelNavigation(): void {
|
||||||
|
this.#index = null;
|
||||||
|
this.#draft = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadComposerHistory(
|
||||||
|
storage: ComposerHistoryStorage,
|
||||||
|
workspaceId: string,
|
||||||
|
): ComposerHistory {
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(composerHistoryStorageKey(workspaceId));
|
||||||
|
if (!raw) return new ComposerHistory();
|
||||||
|
const value = JSON.parse(raw) as unknown;
|
||||||
|
if (!value || typeof value !== "object") return new ComposerHistory();
|
||||||
|
const stored = value as Partial<StoredComposerHistory>;
|
||||||
|
if (
|
||||||
|
stored.version !== COMPOSER_HISTORY_VERSION ||
|
||||||
|
!Array.isArray(stored.entries)
|
||||||
|
) {
|
||||||
|
return new ComposerHistory();
|
||||||
|
}
|
||||||
|
return new ComposerHistory(stored.entries.filter(isHistoryEntry));
|
||||||
|
} catch {
|
||||||
|
return new ComposerHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveComposerHistory(
|
||||||
|
storage: ComposerHistoryStorage,
|
||||||
|
workspaceId: string,
|
||||||
|
history: ComposerHistory,
|
||||||
|
): void {
|
||||||
|
const value: StoredComposerHistory = {
|
||||||
|
version: COMPOSER_HISTORY_VERSION,
|
||||||
|
entries: history.entries,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
storage.setItem(
|
||||||
|
composerHistoryStorageKey(workspaceId),
|
||||||
|
JSON.stringify(value),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// History is an optional convenience; storage failures must not block input submission.
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -636,6 +636,7 @@
|
|||||||
try {
|
try {
|
||||||
const method = composerRequestToProtocolMethod(command.request);
|
const method = composerRequestToProtocolMethod(command.request);
|
||||||
sendProtocolMethod(method);
|
sendProtocolMethod(method);
|
||||||
|
composerInputElement?.recordHistory(value);
|
||||||
composerInputElement?.clear();
|
composerInputElement?.clear();
|
||||||
if (method.method === "run" || method.method === "notify") {
|
if (method.method === "run" || method.method === "notify") {
|
||||||
liveWorkerState = "running";
|
liveWorkerState = "running";
|
||||||
@@ -1602,6 +1603,7 @@
|
|||||||
<div class="composer-input-shell">
|
<div class="composer-input-shell">
|
||||||
<ComposerInput
|
<ComposerInput
|
||||||
bind:this={composerInputElement}
|
bind:this={composerInputElement}
|
||||||
|
historyScope={workspaceId}
|
||||||
ariaLabel="Console input"
|
ariaLabel="Console input"
|
||||||
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
||||||
disabled={!composerEditable}
|
disabled={!composerEditable}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { Segment } from "../src/lib/generated/protocol.ts";
|
||||||
|
import {
|
||||||
|
COMPOSER_HISTORY_LIMIT,
|
||||||
|
ComposerHistory,
|
||||||
|
type ComposerHistoryEntry,
|
||||||
|
composerHistoryStorageKey,
|
||||||
|
loadComposerHistory,
|
||||||
|
saveComposerHistory,
|
||||||
|
shouldBrowseComposerHistory,
|
||||||
|
} from "../src/lib/workspace/console/composer-history.ts";
|
||||||
|
|
||||||
|
function assert(
|
||||||
|
condition: unknown,
|
||||||
|
message = "assertion failed",
|
||||||
|
): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(content: string): ComposerHistoryEntry {
|
||||||
|
return {
|
||||||
|
segments: [{ kind: "text", content }],
|
||||||
|
preserveExactText: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(entryValue: ComposerHistoryEntry | null): string | null {
|
||||||
|
const segment = entryValue?.segments[0];
|
||||||
|
return segment?.kind === "text" ? segment.content : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStorage(initial: Record<string, string> = {}) {
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return values.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
value(key: string): string | null {
|
||||||
|
return values.get(key) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Composer history skips blank and consecutive duplicate entries", () => {
|
||||||
|
const history = new ComposerHistory();
|
||||||
|
|
||||||
|
assert(!history.record(entry(" \n")));
|
||||||
|
assert(history.record(entry("first")));
|
||||||
|
assert(!history.record(entry("first")));
|
||||||
|
assert(history.record(entry("second")));
|
||||||
|
assertEquals(history.entries.map(text), ["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer history keeps the newest 30 entries", () => {
|
||||||
|
const history = new ComposerHistory();
|
||||||
|
for (let index = 0; index < COMPOSER_HISTORY_LIMIT + 4; index += 1) {
|
||||||
|
history.record(entry(`message-${index}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(history.entries.length, COMPOSER_HISTORY_LIMIT);
|
||||||
|
assertEquals(text(history.entries[0] ?? null), "message-4");
|
||||||
|
assertEquals(text(history.entries.at(-1) ?? null), "message-33");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer history uses only the multiline input boundaries", () => {
|
||||||
|
const base = {
|
||||||
|
lineCount: 3,
|
||||||
|
selectionEmpty: true,
|
||||||
|
readOnly: false,
|
||||||
|
composing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert(
|
||||||
|
shouldBrowseComposerHistory({ ...base, direction: "older", cursorLine: 1 }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldBrowseComposerHistory({
|
||||||
|
...base,
|
||||||
|
direction: "older",
|
||||||
|
cursorLine: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
shouldBrowseComposerHistory({ ...base, direction: "newer", cursorLine: 3 }),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldBrowseComposerHistory({
|
||||||
|
...base,
|
||||||
|
direction: "newer",
|
||||||
|
cursorLine: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldBrowseComposerHistory({
|
||||||
|
...base,
|
||||||
|
direction: "older",
|
||||||
|
cursorLine: 1,
|
||||||
|
selectionEmpty: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldBrowseComposerHistory({
|
||||||
|
...base,
|
||||||
|
direction: "newer",
|
||||||
|
cursorLine: 3,
|
||||||
|
readOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!shouldBrowseComposerHistory({
|
||||||
|
...base,
|
||||||
|
direction: "older",
|
||||||
|
cursorLine: 1,
|
||||||
|
composing: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer history navigates older and restores the draft after newer", () => {
|
||||||
|
const history = new ComposerHistory([entry("first"), entry("second")]);
|
||||||
|
|
||||||
|
assertEquals(text(history.previous(entry("unsent draft"))), "second");
|
||||||
|
assertEquals(text(history.previous(entry("ignored draft"))), "first");
|
||||||
|
assertEquals(text(history.previous(entry("ignored draft"))), "first");
|
||||||
|
assertEquals(text(history.next()), "second");
|
||||||
|
assertEquals(text(history.next()), "unsent draft");
|
||||||
|
assert(!history.browsing);
|
||||||
|
assertEquals(history.next(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("editing cancels Composer history navigation", () => {
|
||||||
|
const history = new ComposerHistory([entry("sent")]);
|
||||||
|
history.previous(entry("draft"));
|
||||||
|
assert(history.browsing);
|
||||||
|
|
||||||
|
history.cancelNavigation();
|
||||||
|
|
||||||
|
assert(!history.browsing);
|
||||||
|
assertEquals(history.next(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer history persists segments by workspace and ignores corrupt storage", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const workspaceId = "workspace / one";
|
||||||
|
const history = new ComposerHistory();
|
||||||
|
const paste = {
|
||||||
|
kind: "paste",
|
||||||
|
id: 7,
|
||||||
|
content: "large paste",
|
||||||
|
chars: 11,
|
||||||
|
lines: 1,
|
||||||
|
} satisfies Segment;
|
||||||
|
history.record({ segments: [paste], preserveExactText: true });
|
||||||
|
|
||||||
|
saveComposerHistory(storage, workspaceId, history);
|
||||||
|
const restored = loadComposerHistory(storage, workspaceId);
|
||||||
|
|
||||||
|
assertEquals(restored.entries, history.entries);
|
||||||
|
assertEquals(
|
||||||
|
composerHistoryStorageKey(workspaceId),
|
||||||
|
"yoi.composer-history.v1.workspace.workspace%20%2F%20one",
|
||||||
|
);
|
||||||
|
|
||||||
|
const corrupt = memoryStorage({
|
||||||
|
[composerHistoryStorageKey(workspaceId)]: "not-json",
|
||||||
|
});
|
||||||
|
assertEquals(loadComposerHistory(corrupt, workspaceId).entries, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer input uses boundary-aware Up and Down history navigation", async () => {
|
||||||
|
const inputSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/lib/workspace/console/ComposerInput.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const consoleSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(inputSource.includes('key: "ArrowUp"'));
|
||||||
|
assert(inputSource.includes('key: "ArrowDown"'));
|
||||||
|
assert(inputSource.includes("shouldBrowseComposerHistory({"));
|
||||||
|
assert(inputSource.includes("lineCount: currentView.state.doc.lines"));
|
||||||
|
assert(inputSource.includes("composerHistory.cancelNavigation()"));
|
||||||
|
assert(consoleSource.includes("historyScope={workspaceId}"));
|
||||||
|
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user