Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop

This commit is contained in:
2026-09-03 09:47:53 +09:00
41 changed files with 2218 additions and 379 deletions
+2 -1
View File
@@ -6,7 +6,7 @@
"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",
"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 tests/profile-api.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 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 tests/profile-api.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",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -20,6 +20,7 @@
"@codemirror/language": "npm:@codemirror/language@6.12.4",
"@codemirror/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8",
"@lezer/common": "npm:@lezer/common@1.5.2",
"@lezer/highlight": "npm:@lezer/highlight@1.2.3",
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
"clsx": "npm:clsx@2.1.1",
+2
View File
@@ -9,6 +9,7 @@
"npm:@codemirror/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/common@1.5.2": "1.5.2",
"npm:@lezer/highlight@1.2.3": "1.2.3",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
@@ -1026,6 +1027,7 @@
"npm:@codemirror/language@6.12.4",
"npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8",
"npm:@lezer/common@1.5.2",
"npm:@lezer/highlight@1.2.3",
"npm:@sveltejs/adapter-static@3.0.9",
"npm:@sveltejs/kit@2.49.4",
@@ -43,17 +43,21 @@ Deno.test("root layout leaves Workspace selection explicit", async () => {
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
!layout.includes("/api/workspace") &&
!layout.includes('"/api/workspace"') &&
!layout.includes("redirect(") &&
layout.includes("Workspace selection is explicit"),
"root layout must not infer or redirect to a singleton Workspace",
layout.includes("listWorkspaces(fetch)") &&
layout.includes("accessibleWorkspaces"),
"root layout may list accessible Workspaces but must not infer or redirect to a singleton Workspace",
);
});
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
const [layout, multiplexer] = await Promise.all([
Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
),
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
]);
@@ -328,10 +328,12 @@
</header>
<DecodalSourceEditor
value={source}
readonly={!selectedPath || busy}
readonly={!selectedPath || busy || !analysisReady}
fixedSchemaWrapper={mainSelected}
onChange={(value) => source = value}
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
onComplete={(value, offset, explicit) => analysisReady && toolchain
? toolchain.complete(selectedPath, value, offset, explicit)
: Promise.resolve(null)}
/>
<p class="config-source-status" aria-live="polite">{status}</p>
{#if conflict}
@@ -12,13 +12,18 @@ type ConfigSourceCompletionItem = {
priority: number;
};
export function shouldStartCompletionAfterTyping(
insertedText: string,
): boolean {
return /\S/u.test(insertedText);
}
export function toCodeMirrorCompletion(
source: string,
result: ConfigSourceCompletionResult | null,
): CompletionResult | null {
if (!result) return null;
return {
from: utf8ByteOffsetToUtf16(source, result.from),
from: result.from,
options: result.items.map((item) => ({
label: item.label,
type: item.kind,
@@ -27,31 +32,3 @@ export function toCodeMirrorCompletion(
})),
};
}
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
throw new RangeError(
"completion byte offset must be a non-negative integer",
);
}
let bytes = 0;
let utf16 = 0;
for (const character of source) {
if (bytes === byteOffset) return utf16;
const codePoint = character.codePointAt(0)!;
bytes += codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;
utf16 += character.length;
if (bytes > byteOffset) {
throw new RangeError("completion byte offset splits a UTF-8 code point");
}
}
if (bytes === byteOffset) return utf16;
throw new RangeError("completion byte offset is outside the source");
}
@@ -79,7 +79,7 @@ export class ConfigSourceToolchain {
utf16Offset,
explicit,
});
return toCodeMirrorCompletion(source, result);
return toCodeMirrorCompletion(result);
}
format(source: string): Promise<string> {
return this.#request({ kind: "format", source });
@@ -0,0 +1,7 @@
export const CODEMIRROR_VITE_DEDUPE = [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
];
@@ -37,12 +37,21 @@
type ComposerPaste,
type ComposerTextPaste,
} 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";
interface Props {
disabled?: boolean;
ariaLabel?: string;
ariaKeyShortcuts?: string;
historyScope: string;
onchange?: (snapshot: ComposerDraftSnapshot) => void;
onkeydown?: (event: KeyboardEvent) => void;
onsubmit?: () => void;
@@ -52,6 +61,7 @@
disabled = false,
ariaLabel = "Message",
ariaKeyShortcuts = "Meta+Enter Control+Enter",
historyScope,
onchange,
onkeydown,
onsubmit,
@@ -59,6 +69,8 @@
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1;
let nextPasteKey = 1;
const editable = new Compartment();
@@ -179,6 +191,40 @@
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 {
if (!view) return;
const selection = view.state.selection.main;
@@ -273,6 +319,10 @@
return true;
}
$effect(() => {
composerHistory = loadComposerHistory(localStorage, historyScope);
});
onMount(() => {
view = new EditorView({
parent: mountElement,
@@ -292,6 +342,14 @@
key: "Mod-y",
run: (currentView) => currentView.state.readOnly,
},
{
key: "ArrowUp",
run: (currentView) => browseHistory(currentView, "older"),
},
{
key: "ArrowDown",
run: (currentView) => browseHistory(currentView, "newer"),
},
{
key: "Backspace",
run: (currentView) =>
@@ -319,6 +377,7 @@
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
}
@@ -396,6 +455,16 @@
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 {
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.
}
}
@@ -358,9 +358,10 @@ Deno.test("root layout keeps Workspace selection explicit", async () => {
assert(
layoutLoad.includes("export const load") &&
layoutLoad.includes("() => ({})") &&
layoutLoad.includes("listWorkspaces(fetch)") &&
layoutLoad.includes("accessibleWorkspaces") &&
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes("/api/workspace") &&
!layoutLoad.includes('"/api/workspace"') &&
!layoutLoad.includes("workspaceRoute") &&
!layoutLoad.includes("redirect("),
"root layout should not infer, bootstrap, or redirect through a singleton Workspace",
@@ -812,6 +813,9 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
);
const globalNavSections = await Deno.readTextFile(
new URL("../sidebar/GlobalNavSections.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL("../sidebar/SidebarFrame.svelte", import.meta.url),
);
@@ -874,13 +878,23 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Root layout chrome should keep GlobalSidebar as the root slot owner while account navigation stays in the header",
);
assert(
globalSidebar.includes('aria-label="Global pages"') &&
!globalSidebar.includes('<p class="sidebar-section-label">') &&
globalSidebar.includes("/account") &&
globalSidebar.includes("/login/device") &&
!globalSidebar.includes("Tickets") &&
!globalSidebar.includes("Repositories"),
"Root default sidebar should contain only global navigation, not workspace-scoped sections",
globalSidebar.includes("GlobalNavSections") &&
globalNavSections.includes('aria-label="Global pages"') &&
!globalNavSections.includes('<p class="sidebar-section-label">') &&
globalNavSections.includes('"/account"') &&
globalNavSections.includes('"/login/device"') &&
!globalNavSections.includes('label: "Workspaces"') &&
globalNavSections.includes("sidebar-nav-section--category") &&
globalNavSections.includes("global-workspaces-heading") &&
globalNavSections.includes("workspaces") &&
globalNavSections.includes("workspace.display_name") &&
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
rootLayout.includes("workspaces={data.accessibleWorkspaces}") &&
rootLayout.includes("workspaceError={data.workspaceCatalogError}") &&
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
!globalNavSections.includes("Tickets") &&
!globalNavSections.includes("Repositories"),
"Top-level sidebar should replace the Workspaces button with a categorized accessible Workspace list below the remaining global navigation",
);
assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
@@ -918,7 +932,8 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
);
assert(
rootLayoutLoad.includes("export const load") &&
rootLayoutLoad.includes("() => ({})") &&
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
rootLayoutLoad.includes("accessibleWorkspaces") &&
!rootLayoutLoad.includes("workspaceRoute") &&
!rootLayoutLoad.includes("redirect("),
"Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely",
@@ -1,11 +1,20 @@
<script lang="ts">
import { untrack } from 'svelte';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import {
autocompletion,
closeCompletion,
completionKeymap,
completionStatus,
startCompletion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror';
import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts';
import {
fixedSchemaWrapperExtension,
moveSelectionIntoFixedWrapper,
@@ -69,6 +78,39 @@
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
});
function scheduleCompletion(editor: EditorView) {
queueMicrotask(() => {
if (
editor.hasFocus &&
!editor.state.facet(EditorState.readOnly) &&
completionStatus(editor.state) === null
) {
startCompletion(editor);
}
});
}
function typedText(update: ViewUpdate): string | null {
let foundTyping = false;
let insertedText = '';
for (const transaction of update.transactions) {
if (!transaction.isUserEvent('input.type')) continue;
foundTyping = true;
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
insertedText += inserted.toString();
});
}
return foundTyping ? insertedText : null;
}
function dismissCompletion(editor: EditorView) {
queueMicrotask(() => {
if (completionStatus(editor.state) !== null) closeCompletion(editor);
});
}
const completionKeymapWithoutEnter = completionKeymap.filter((binding) => binding.key !== 'Enter');
$effect(() => {
if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value);
@@ -86,11 +128,17 @@
highlightActiveLine(),
decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
}] })] : []),
keymap.of([]),
autocompletion({
activateOnTyping: false,
override: [
async (context: CompletionContext) => {
if (!handleComplete) return null;
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
},
],
}),
keymap.of(completionKeymapWithoutEnter),
fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
),
@@ -99,7 +147,17 @@
EditorView.editable.of(!initialReadonly),
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) handleChange(update.state.doc.toString());
if (update.docChanged) {
handleChange(update.state.doc.toString());
const insertedText = typedText(update);
if (insertedText !== null) {
if (shouldStartCompletionAfterTyping(insertedText)) {
scheduleCompletion(update.view);
} else {
dismissCompletion(update.view);
}
}
}
}),
theme,
],
@@ -0,0 +1,70 @@
<script lang="ts">
import type { WorkspaceCatalogRecord } from "$lib/workspace/api/workspace-catalog";
type Props = {
currentPath: string;
workspaces?: WorkspaceCatalogRecord[] | null;
workspaceError?: string | null;
};
let {
currentPath,
workspaces = null,
workspaceError = null,
}: Props = $props();
const globalItems = [
{ href: "/#workspace-create-title", label: "Create Workspace" },
{ href: "/account", label: "Account" },
{ href: "/login/device", label: "Device Login" },
];
function workspaceHref(workspaceId: string): string {
return `/w/${encodeURIComponent(workspaceId)}`;
}
</script>
<nav class="sidebar-sections" aria-label="Global pages">
<section class="sidebar-nav-section">
<div class="sidebar-list">
{#each globalItems as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? "page" : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</div>
</section>
{#if workspaces !== null}
<section class="sidebar-nav-section sidebar-nav-section--category" aria-labelledby="global-workspaces-heading">
<h2 id="global-workspaces-heading" class="sidebar-nav-section__header">workspaces</h2>
{#if workspaceError}
<p class="workspace-status error">Workspace list unavailable.</p>
{/if}
{#if workspaces.length > 0}
<div class="sidebar-list">
{#each workspaces as workspace (workspace.workspace_id)}
{@const href = workspaceHref(workspace.workspace_id)}
<a
class="sidebar-link"
class:active={currentPath === href}
{href}
aria-current={currentPath === href ? "page" : undefined}
>
<span>{workspace.display_name}</span>
</a>
{/each}
</div>
{:else if !workspaceError}
<p class="workspace-status">No accessible Workspaces.</p>
{/if}
</section>
{/if}
</nav>
@@ -1,20 +1,22 @@
<script lang="ts">
import type { WorkspaceCatalogRecord } from '../api/workspace-catalog';
import type { SidebarSnippet } from './context';
import GlobalNavSections from './GlobalNavSections.svelte';
import './sidebar.css';
type Props = {
currentPath: string;
content?: SidebarSnippet | null;
workspaces?: WorkspaceCatalogRecord[];
workspaceError?: string | null;
};
const { currentPath, content = null }: Props = $props();
const items = [
{ href: '/', label: 'Workspaces' },
{ href: '/#workspace-create-title', label: 'Create Workspace' },
{ href: '/account', label: 'Account' },
{ href: '/login/device', label: 'Device Login' },
];
const {
currentPath,
content = null,
workspaces = [],
workspaceError = null,
}: Props = $props();
</script>
{#if content}
@@ -22,18 +24,7 @@
{:else}
<div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section">
<nav class="sidebar-list" aria-label="Global pages">
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
<GlobalNavSections {currentPath} {workspaces} {workspaceError} />
</div>
</div>
{/if}
@@ -1,6 +1,12 @@
<script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/console/model';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from './worker-actions';
import {
workspaceWorkersStore,
type SidebarWorker,
@@ -8,6 +14,7 @@
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
const COLLAPSED_WORKER_COUNT = 6;
type WorkerActionKind = 'stop' | 'delete';
type Props = {
currentPath?: string;
@@ -19,6 +26,10 @@
let error = $state<string | null>(null);
let workers = $state<SidebarWorker[]>([]);
let expanded = $state(false);
let openWorkerKey = $state<string | null>(null);
let menuElement = $state<HTMLElement | null>(null);
let menuTrigger = $state<HTMLButtonElement | null>(null);
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let visibleWorkers = $derived(
expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT),
);
@@ -26,8 +37,95 @@
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
);
function workerKey(worker: SidebarWorker): string {
return `${worker.runtime_id}:${worker.worker_id}`;
}
function isBusy(worker: SidebarWorker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function closeWorkerMenu(restoreFocus = false) {
const trigger = menuTrigger;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
if (restoreFocus) queueMicrotask(() => trigger?.focus());
}
function toggleWorkerMenu(worker: SidebarWorker, trigger: HTMLButtonElement) {
const key = workerKey(worker);
if (openWorkerKey === key) {
closeWorkerMenu();
return;
}
openWorkerKey = key;
menuTrigger = trigger;
queueMicrotask(() => {
menuElement?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
}
function handleWindowClick(event: MouseEvent) {
if (!openWorkerKey) return;
const target = event.target;
const owner = target instanceof Element ? target.closest('[data-worker-actions]') : null;
if (owner?.getAttribute('data-worker-actions') !== openWorkerKey) closeWorkerMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !openWorkerKey) return;
event.preventDefault();
closeWorkerMenu(true);
}
async function stopWorker(worker: SidebarWorker) {
if (busyAction || !worker.capabilities.can_stop) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
try {
await stopSidebarWorker(workspaceId, worker);
workers = workers.map((item) =>
workerKey(item) === workerKey(worker)
? { ...item, state: 'stopped', capabilities: { ...item.capabilities, can_stop: false } }
: item
);
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
title: 'Worker stop failed',
});
} finally {
busyAction = null;
}
}
async function deleteWorker(worker: SidebarWorker) {
if (busyAction || !canDeleteSidebarWorker(worker)) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
await deleteSidebarWorker(workspaceId, worker);
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
title: 'Worker deleted',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
}
}
$effect(() => {
expanded = false;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => {
loading = state.loading;
@@ -37,6 +135,8 @@
});
</script>
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
<section class="sidebar-nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">
@@ -70,7 +170,9 @@
{#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker, workspaceId)}
{@const activity = sidebarWorkerActivity(worker)}
<li>
{@const key = workerKey(worker)}
{@const label = worker.display_name || worker.label}
<li class="worker-nav-item" data-worker-actions={key}>
<a
href={href}
class="worker-nav-link"
@@ -86,11 +188,47 @@
<span class="worker-status-dot" aria-label="Idle"></span>
{/if}
</span>
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
<span class="worker-nav-label">{label}</span>
<small class="worker-nav-meta">
{worker.repository_key ?? '—'}・{worker.working_directory_id ?? '—'}
</small>
</a>
<button
class="worker-actions-trigger"
class:open={openWorkerKey === key}
type="button"
aria-label={`Actions for ${label}`}
aria-haspopup="menu"
aria-expanded={openWorkerKey === key}
onclick={(event) => toggleWorkerMenu(worker, event.currentTarget)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.5"></circle>
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
</svg>
</button>
{#if openWorkerKey === key}
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
<button
type="button"
role="menuitem"
disabled={busyAction !== null || !worker.capabilities.can_stop}
onclick={() => stopWorker(worker)}
>
{isBusy(worker, 'stop') ? 'Stopping…' : 'Stop'}
</button>
<button
class="danger"
type="button"
role="menuitem"
disabled={busyAction !== null || !canDeleteSidebarWorker(worker)}
onclick={() => deleteWorker(worker)}
>
{isBusy(worker, 'delete') ? 'Deleting…' : 'Delete'}
</button>
</div>
{/if}
</li>
{/each}
</ul>
@@ -410,6 +410,91 @@
a.sidebar-link.active {
background: var(--sidebar-item-active);
}
.worker-nav-item {
position: relative;
min-width: 0;
}
.worker-actions-trigger {
position: absolute;
z-index: 2;
top: 0.35rem;
right: 0.35rem;
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 0;
border-radius: var(--radius-soft);
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
}
.worker-actions-trigger svg {
width: 1rem;
height: 1rem;
fill: currentColor;
}
.worker-nav-item:hover .worker-actions-trigger,
.worker-nav-item:focus-within .worker-actions-trigger,
.worker-actions-trigger.open {
opacity: 1;
pointer-events: auto;
}
.worker-actions-trigger:hover,
.worker-actions-trigger:focus-visible,
.worker-actions-trigger.open {
background: var(--interactive-hover);
color: var(--text-strong);
}
.worker-actions-trigger:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.worker-actions-menu {
position: absolute;
z-index: 20;
top: 2rem;
right: 0.35rem;
display: grid;
min-width: 7rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
}
.worker-actions-menu button {
border: 0;
border-radius: var(--radius-soft);
padding: var(--space-2);
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 0.76rem;
font-weight: 650;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.worker-actions-menu button:hover:not(:disabled),
.worker-actions-menu button:focus-visible {
background: var(--sidebar-item-hover);
}
.worker-actions-menu button:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.worker-actions-menu button.danger {
color: var(--danger);
}
.worker-actions-menu button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.worker-nav-link {
display: grid;
grid-template-columns: 0.75rem minmax(0, 1fr);
@@ -418,6 +503,7 @@
row-gap: 0.1rem;
margin: 0.0625rem 0;
padding: var(--space-2);
padding-right: 2.35rem;
border-radius: var(--radius-soft);
color: var(--text-muted);
text-decoration: none;
@@ -0,0 +1,123 @@
import { workspaceApiPath } from "$lib/workspace/api/http";
import type {
Diagnostic,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
Worker,
} from "./types";
type FetchFn = typeof fetch;
type WorkerLifecycleResponse = {
state: string;
diagnostics?: Diagnostic[];
};
function workerPath(workspaceId: string, worker: Worker): string {
return workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${
encodeURIComponent(worker.worker_id)
}`,
);
}
async function responseError(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText}`.trim();
try {
const payload = await response.json() as {
message?: string;
error?: { message?: string };
};
return payload.error?.message ?? payload.message ?? fallback;
} catch {
return fallback;
}
}
function diagnosticMessage(
diagnostics: Diagnostic[] | undefined,
fallback: string,
): string {
return diagnostics?.find((diagnostic) => diagnostic.severity === "error")
?.message ??
diagnostics?.[0]?.message ??
fallback;
}
export function canDeleteSidebarWorker(worker: Worker): boolean {
return worker.state === "stopped" || worker.state === "cancelled";
}
export async function stopSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason: "stopped from Workspace sidebar" }),
});
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json() as WorkerLifecycleResponse;
if (result.state !== "accepted") {
throw new Error(
diagnosticMessage(result.diagnostics, `Worker stop was ${result.state}`),
);
}
}
export async function deleteSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const runtimePath = `/runtimes/${encodeURIComponent(worker.runtime_id)}`;
const planResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-plan`),
);
if (!planResponse.ok) throw new Error(await responseError(planResponse));
const plan = await planResponse.json() as RuntimeCleanupPlanResponse;
const candidate = plan.workers.find((item) =>
item.runtime_id === worker.runtime_id &&
item.runtime_worker_id === worker.worker_id
);
if (!candidate) throw new Error("Worker is not available for deletion");
if (candidate.blocking_reason) throw new Error(candidate.blocking_reason);
const executionResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-executions`),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_plan_revision: plan.revision,
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
if (!executionResponse.ok) {
throw new Error(await responseError(executionResponse));
}
const execution = await executionResponse
.json() as RuntimeCleanupExecutionResponse;
const outcome = execution.results.find((result) =>
result.target_id === candidate.target_id
);
if (!outcome || outcome.status !== "deleted") {
throw new Error(
outcome?.message ??
diagnosticMessage(
execution.diagnostics,
"Worker deletion was not completed",
),
);
}
}
+7 -2
View File
@@ -10,7 +10,7 @@
import '../app.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
let { children, data }: LayoutProps = $props();
let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
sidebar = activeSidebar;
@@ -27,7 +27,12 @@
<div class="app-shell">
<SidebarFrame>
<GlobalSidebar currentPath={page.url.pathname} content={sidebar} />
<GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
workspaces={data.accessibleWorkspaces}
workspaceError={data.workspaceCatalogError}
/>
</SidebarFrame>
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
+17 -4
View File
@@ -1,5 +1,18 @@
import type { LayoutLoad } from './$types';
import { listWorkspaces } from "$lib/workspace/api/workspace-catalog";
import type { LayoutLoad } from "./$types";
// Workspace selection is explicit at `/`; the root layout must never infer a
// singleton Workspace or redirect based on an unscoped compatibility endpoint.
export const load: LayoutLoad = () => ({});
export const load: LayoutLoad = async ({ fetch }) => {
try {
return {
accessibleWorkspaces: await listWorkspaces(fetch),
workspaceCatalogError: null,
};
} catch (error) {
return {
accessibleWorkspaces: [],
workspaceCatalogError: error instanceof Error
? error.message
: "Unable to load Workspaces",
};
}
};
@@ -776,6 +776,7 @@
try {
const method = composerRequestToProtocolMethod(command.request);
sendProtocolMethod(method);
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "run" || method.method === "notify") {
@@ -1763,6 +1764,7 @@
/>
<ComposerInput
bind:this={composerInputElement}
historyScope={workspaceId}
ariaLabel="Console input"
ariaKeyShortcuts="Meta+Enter Control+Enter"
disabled={!composerEditable}
+201
View File
@@ -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)"));
});
@@ -1,3 +1,6 @@
import denoConfig from "../../deno.json" with { type: "json" };
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>;
@@ -7,6 +10,30 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
for (
const packageName of [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
]
) {
assert(
CODEMIRROR_VITE_DEDUPE.includes(packageName),
`Vite must deduplicate ${packageName}`,
);
}
for (const packageName of CODEMIRROR_VITE_DEDUPE) {
assert(
packageName in (denoConfig.imports ?? {}),
`${packageName} must be a direct dependency so Vite can deduplicate it`,
);
}
});
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile(
new URL(
@@ -72,6 +99,17 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist",
);
assert(
source.includes("keymap.of(completionKeymapWithoutEnter)") &&
source.includes("binding.key !== 'Enter'") &&
source.includes("activateOnTyping: false") &&
source.includes("shouldStartCompletionAfterTyping(insertedText)") &&
source.includes("startCompletion(editor)") &&
!source.includes("EditorView.domEventHandlers") &&
!source.includes("update.selectionSet") &&
source.includes("completionStatus(editor.state) === null"),
"completion should start only after non-whitespace typing, without using focus, cursor movement, Space, or Enter",
);
assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") &&
@@ -3,7 +3,10 @@ declare const Deno: {
readTextFile(path: URL): Promise<string>;
};
import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts";
import {
shouldStartCompletionAfterTyping,
toCodeMirrorCompletion,
} from "../../src/lib/workspace/config-source/completion.ts";
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
function assert(condition: unknown, message: string): asserts condition {
@@ -67,10 +70,28 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
);
});
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
const source = "let 名 = tru";
const result = toCodeMirrorCompletion(source, {
from: new TextEncoder().encode("let 名 = ").length,
Deno.test("completion starts only after non-whitespace typing", () => {
assert(
!shouldStartCompletionAfterTyping(" "),
"Space should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\n"),
"Enter should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\t"),
"other whitespace should not start completion",
);
assert(
shouldStartCompletionAfterTyping("p"),
"non-whitespace typing should start completion",
);
});
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
const result = toCodeMirrorCompletion({
from: "let 名 = ".length,
items: [
{
label: "true",
@@ -84,7 +105,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
assert(result !== null, "WASM completion should produce a CodeMirror result");
assert(
result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets",
"WASM UTF-16 offsets should be preserved for CodeMirror",
);
assert(
result.options.length === 1,
@@ -299,6 +299,45 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
assertEquals(result.items[0].kind, "file");
});
Deno.test("generated WASM completes blank nested schema positions after Unicode", () => {
const source =
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
const cursor = source.indexOf("{ }") + 2;
set_snapshot({
...snapshot,
entries: {
...snapshot.entries,
"workspace.dcdl": {
...snapshot.entries["workspace.dcdl"],
content: source,
},
},
});
set_schema_bundle({
contributions: [],
source: "{ profile = { default_profile = String; }; prompts = {}; }",
fingerprint: "sha256:test-schema",
});
const result = complete_current(
"workspace.dcdl",
source,
cursor,
true,
) as {
from: number;
items: Array<{ label: string; kind: string }>;
};
assertEquals(result.from, cursor);
assertEquals(
result.items.some((item) => item.label === "default_profile"),
true,
);
assertEquals(result.items.some((item) => item.label === "profile"), false);
assertEquals(result.items.some((item) => item.label === "prompts"), false);
});
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
const bareSource = "{ pro }";
const source = "{ pro } as WorkspaceConfigSchema";
@@ -0,0 +1,180 @@
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.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}`);
}
}
async function assertRejects(
operation: () => Promise<unknown>,
message: string,
): Promise<void> {
try {
await operation();
} catch (cause) {
assert(cause instanceof Error, "expected an Error");
assert(
cause.message.includes(message),
`expected error containing ${message}`,
);
return;
}
throw new Error("expected operation to reject");
}
const worker = {
runtime_id: "runtime /",
worker_id: "worker /",
state: "running",
capabilities: { can_stop: true },
} as Worker;
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
Deno.test("sidebar Stop uses the workspace-scoped Worker lifecycle endpoint", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({ state: "accepted", diagnostics: [] });
}) as typeof fetch;
await stopSidebarWorker("team space", worker, fetchFn);
assertEquals(requests.length, 1);
assertEquals(
requests[0]?.url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/stop",
);
assertEquals(requests[0]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[0]?.init?.body)), {
reason: "stopped from Workspace sidebar",
});
});
Deno.test("sidebar Stop rejects non-accepted lifecycle responses", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
state: "rejected",
diagnostics: [{ severity: "error", message: "Worker cannot stop" }],
}),
)) as typeof fetch;
await assertRejects(
() => stopSidebarWorker("workspace", worker, fetchFn),
"Worker cannot stop",
);
});
Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
if (requests.length === 1) {
return jsonResponse({
revision: 7,
digest: "digest-7",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: null,
}],
workdirs: [],
diagnostics: [],
});
}
return jsonResponse({
results: [{
target_id: "worker-target",
status: "deleted",
message: null,
}],
diagnostics: [],
});
}) as typeof fetch;
await deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn);
assertEquals(requests.map((request) => request.url), [
"/api/w/team/runtimes/runtime%20%2F/cleanup-plan",
"/api/w/team/runtimes/runtime%20%2F/cleanup-executions",
]);
assertEquals(requests[1]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
expected_plan_revision: 7,
expected_plan_digest: "digest-7",
worker_target_ids: ["worker-target"],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
});
});
Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
revision: 8,
digest: "digest-8",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: "Worker is pinned",
}],
workdirs: [],
diagnostics: [],
}),
)) as typeof fetch;
await assertRejects(
() => deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn),
"Worker is pinned",
);
});
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
assert(!canDeleteSidebarWorker(worker));
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
});
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/sidebar/WorkersNavSection.svelte",
import.meta.url,
),
);
const styles = await Deno.readTextFile(
new URL("../../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});
+5
View File
@@ -1,9 +1,14 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
export default defineConfig({
plugins: [sveltekit()],
resolve: {
dedupe: CODEMIRROR_VITE_DEDUPE,
},
server: {
host: "localhost",
port: 5173,