From 8197ff32966c75e662efefd2229c7babbf3cca25 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 15 Jan 2026 21:03:05 +0100 Subject: [PATCH 1/2] feat(vscode): improve override mechanism This will be changed a bit, this code is pretty much written by AI. --- vscode/src/commands/generate-override.ts | 64 ++- vscode/src/commands/open-on-unicode.ts | 31 +- .../src/composables/useOverrideGenerator.ts | 383 +++++++++++++----- vscode/src/composables/useSelectionView.ts | 372 ++++++++++++++--- vscode/src/lib/override-schema.ts | 66 +++ 5 files changed, 752 insertions(+), 164 deletions(-) diff --git a/vscode/src/commands/generate-override.ts b/vscode/src/commands/generate-override.ts index a67754ce7..959717850 100644 --- a/vscode/src/commands/generate-override.ts +++ b/vscode/src/commands/generate-override.ts @@ -34,6 +34,23 @@ function extractVersionFromContent(content: string): string { return "16.0.0"; } +function getSelectionSummary(generator: ReturnType): string { + const activeSection = generator.activeSection.value; + const definition = generator.activeSectionDefinition.value; + + if (!activeSection || !definition) { + return ""; + } + + if (definition.mode === "range" && activeSection.range) { + return `${definition.label}: lines ${activeSection.range.start}-${activeSection.range.end}`; + } else if (definition.mode === "lines" && activeSection.lines.length > 0) { + return `${definition.label}: ${activeSection.lines.length} line(s)`; + } + + return `${definition.label}: not set`; +} + export function useGenerateOverrideCommand() { const activeEditor = useActiveTextEditor(); const generator = useOverrideGenerator(); @@ -47,21 +64,44 @@ export function useGenerateOverrideCommand() { } if (generator.mode.value === "selecting") { - const action = await window.showQuickPick( - [ - { - label: `$(check) Confirm (lines ${generator.selectionStart.value}-${generator.selectionEnd.value})`, - action: "confirm", - }, - { label: "$(close) Cancel Selection", action: "cancel" }, - ], + const doneCount = generator.doneSections.value.length; + const totalCount = generator.sections.value.length; + const summary = getSelectionSummary(generator); + + const items = [ { - placeHolder: "Override selection is active. Click lines in editor to adjust.", + label: generator.activeSection.value + ? `$(check) Confirm ${generator.activeSectionDefinition.value?.label ?? "Section"}` + : `$(check) Finish (${doneCount}/${totalCount} sections)`, + action: "confirm" as const, + description: summary, }, - ); + { label: "$(close) Cancel Selection", action: "cancel" as const }, + ]; + + const action = await window.showQuickPick(items, { + placeHolder: generator.activeSection.value + ? `Editing ${generator.activeSectionDefinition.value?.label}. Click lines in editor to adjust.` + : "All sections complete. Confirm to generate override.", + }); if (action?.action === "confirm") { - const override = generator.confirm(); + if (generator.activeSection.value) { + const confirmed = generator.confirmActiveSection(); + if (!confirmed) { + window.showWarningMessage("Selection is not valid. Please adjust the selection."); + return; + } + + if (generator.activeSection.value) { + window.showInformationMessage( + `Section confirmed. Now editing: ${generator.activeSectionDefinition.value?.label}`, + ); + return; + } + } + + const override = generator.confirmAll(); if (override) { const json = JSON.stringify(override, null, 2); await env.clipboard.writeText(json); @@ -86,7 +126,7 @@ export function useGenerateOverrideCommand() { await vscodeCommands.executeCommand("ucd:selection.focus"); window.showInformationMessage( - `Selection mode active (lines ${detected.start}-${detected.end}). Click to set start, click again to set end. Run command again to confirm.`, + `Selection mode active. Editing: Heading (lines ${detected.start}-${detected.end}). Click to adjust. Run command again to confirm.`, ); }); } diff --git a/vscode/src/commands/open-on-unicode.ts b/vscode/src/commands/open-on-unicode.ts index 592f0f4fc..b95ad12e5 100644 --- a/vscode/src/commands/open-on-unicode.ts +++ b/vscode/src/commands/open-on-unicode.ts @@ -19,8 +19,35 @@ export function useOpenOnUnicodeCommand() { return; } - // TODO: This would allow to traverse upwards, this should be blocked. - executeCommand("vscode.open", Uri.parse(`https://unicode.org/Public/${treeViewOrUri.path}`)); + // Sanitize path to prevent directory traversal attacks + const rawPath = treeViewOrUri.path; + + // Normalize the path and check for traversal attempts + // Use a simple approach: split, filter out dangerous segments, rejoin + const segments = rawPath.split("/").filter((segment) => { + // Block empty segments, current dir refs, and parent dir refs + return segment !== "" && segment !== "." && segment !== ".."; + }); + + // If no valid segments remain, block the request + if (segments.length === 0) { + logger.error("Invalid path provided to openOnUnicode command: path is empty or invalid."); + return; + } + + // Check if any segment still contains traversal patterns (encoded or otherwise) + const hasTraversal = segments.some((segment) => { + const decoded = decodeURIComponent(segment); + return decoded === ".." || decoded === "." || decoded.includes("../") || decoded.includes("..\\"); + }); + + if (hasTraversal) { + logger.error("Invalid path provided to openOnUnicode command: path traversal detected."); + return; + } + + const sanitizedPath = segments.join("/"); + executeCommand("vscode.open", Uri.parse(`https://unicode.org/Public/${sanitizedPath}`)); return; } diff --git a/vscode/src/composables/useOverrideGenerator.ts b/vscode/src/composables/useOverrideGenerator.ts index 01b87386f..9f787cd1d 100644 --- a/vscode/src/composables/useOverrideGenerator.ts +++ b/vscode/src/composables/useOverrideGenerator.ts @@ -1,75 +1,146 @@ -import type { Disposable, TextEditor, TextEditorSelectionChangeEvent } from "vscode"; -import type { HeadingOverride, ParserOverride, Position } from "../lib/override-schema"; +import type { Disposable, TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent } from "vscode"; +import type { AnnotationsOverride, HeadingOverride, ParserOverride, SectionId, SectionState } from "../lib/override-schema"; import { computed, createSingletonComposable, ref } from "reactive-vscode"; import { window } from "vscode"; -import { createParserOverride, isValidPosition, serializeOverride } from "../lib/override-schema"; +import { + createInitialSectionStates, + createParserOverride, + getSectionDefinition, + isSectionSelectionValid, + SECTION_DEFINITIONS, + serializeOverride, +} from "../lib/override-schema"; export type SelectionMode = "idle" | "selecting"; export type ClickMode = "set-start" | "set-end"; -const headingHighlightDecoration = window.createTextEditorDecorationType({ - backgroundColor: "rgba(255, 213, 79, 0.3)", - isWholeLine: true, -}); +const decorationCache = new Map(); + +function getDecorationsForSection(sectionId: SectionId) { + const cached = decorationCache.get(sectionId); + if (cached) { + return cached; + } -const selectionStartDecoration = window.createTextEditorDecorationType({ - backgroundColor: "rgba(76, 175, 80, 0.4)", - isWholeLine: true, - before: { - contentText: "▶ START", - color: "rgba(76, 175, 80, 1)", - fontWeight: "bold", - margin: "0 8px 0 0", - }, -}); + const definition = getSectionDefinition(sectionId); + if (!definition) { + throw new Error(`Unknown section: ${sectionId}`); + } -const selectionEndDecoration = window.createTextEditorDecorationType({ - backgroundColor: "rgba(244, 67, 54, 0.4)", - isWholeLine: true, - before: { - contentText: "◼ END", - color: "rgba(244, 67, 54, 1)", - fontWeight: "bold", - margin: "0 8px 0 0", - }, -}); + const decorations = { + highlight: window.createTextEditorDecorationType({ + backgroundColor: definition.highlightColor, + isWholeLine: true, + }), + start: window.createTextEditorDecorationType({ + backgroundColor: definition.markerColor, + isWholeLine: true, + before: { + contentText: "▶ START", + color: definition.markerColor, + fontWeight: "bold", + margin: "0 8px 0 0", + }, + }), + end: window.createTextEditorDecorationType({ + backgroundColor: definition.markerColor, + isWholeLine: true, + before: { + contentText: "◼ END", + color: definition.markerColor, + fontWeight: "bold", + margin: "0 8px 0 0", + }, + }), + line: window.createTextEditorDecorationType({ + backgroundColor: definition.highlightColor, + isWholeLine: true, + before: { + contentText: "●", + color: definition.markerColor, + fontWeight: "bold", + margin: "0 8px 0 0", + }, + }), + }; + + decorationCache.set(sectionId, decorations); + return decorations; +} export const useOverrideGenerator = createSingletonComposable(() => { const mode = ref("idle"); const clickMode = ref("set-start"); - const selectionStart = ref(null); - const selectionEnd = ref(null); const fileName = ref(null); const unicodeVersion = ref(null); const activeEditor = ref(null); + const sections = ref([]); + const activeSectionId = ref(null); + let selectionChangeDisposable: Disposable | null = null; - const currentPosition = computed(() => { - if (selectionStart.value === null || selectionEnd.value === null) { - return null; - } - return { - start: selectionStart.value, - end: selectionEnd.value, - }; + const activeSection = computed(() => { + if (!activeSectionId.value) return null; + return sections.value.find((s) => s.id === activeSectionId.value) ?? null; }); - const isValid = computed(() => { - const pos = currentPosition.value; - return pos !== null && isValidPosition(pos); + const activeSectionDefinition = computed(() => { + if (!activeSectionId.value) return null; + return getSectionDefinition(activeSectionId.value) ?? null; + }); + + const pendingSections = computed(() => { + return sections.value.filter((s) => s.status === "pending"); + }); + + const doneSections = computed(() => { + return sections.value.filter((s) => s.status === "done"); + }); + + const allSectionsDone = computed(() => { + return sections.value.length > 0 && sections.value.every((s) => s.status === "done"); }); const currentOverride = computed(() => { - if (!fileName.value || !unicodeVersion.value || !currentPosition.value) { + if (!fileName.value || !unicodeVersion.value) { return null; } - const heading: HeadingOverride = { - position: currentPosition.value, + const headingState = sections.value.find((s) => s.id === "heading"); + let heading: HeadingOverride | undefined; + + if (headingState?.status === "done" && headingState.range) { + heading = { position: headingState.range }; + } + + const annotationsState = sections.value.find((s) => s.id === "annotations"); + let annotations: AnnotationsOverride | undefined; + + if (annotationsState?.status === "done" && annotationsState.lines.length > 0) { + annotations = { lines: [...annotationsState.lines] }; + } + + const override: ParserOverride = { + version: 1, + fileName: fileName.value, + unicodeVersion: unicodeVersion.value, }; - return createParserOverride(fileName.value, unicodeVersion.value, heading); + if (heading) { + override.heading = heading; + } + + if (annotations) { + override.annotations = annotations; + } + + return override; }); const overrideJson = computed(() => { @@ -80,27 +151,52 @@ export const useOverrideGenerator = createSingletonComposable(() => { function onSelectionChange(event: TextEditorSelectionChangeEvent) { if (mode.value !== "selecting") return; if (event.textEditor !== activeEditor.value) return; + if (!activeSectionId.value) return; const selection = event.selections[0]; if (!selection) return; const clickedLine = selection.active.line; + const section = activeSection.value; + const definition = activeSectionDefinition.value; + + if (!section || !definition) return; + + if (definition.mode === "range") { + handleRangeClick(section, clickedLine); + } else { + handleLineClick(section, clickedLine); + } + updateDecorations(); + } + + function handleRangeClick(section: SectionState, clickedLine: number) { if (clickMode.value === "set-start") { - selectionStart.value = clickedLine; - if (selectionEnd.value !== null && clickedLine > selectionEnd.value) { - selectionEnd.value = clickedLine; - } + const currentEnd = section.range?.end ?? clickedLine; + section.range = { + start: clickedLine, + end: Math.max(clickedLine, currentEnd), + }; clickMode.value = "set-end"; } else { - selectionEnd.value = clickedLine; - if (selectionStart.value !== null && clickedLine < selectionStart.value) { - selectionStart.value = clickedLine; - } + const currentStart = section.range?.start ?? clickedLine; + section.range = { + start: Math.min(currentStart, clickedLine), + end: clickedLine, + }; clickMode.value = "set-start"; } + } - updateDecorations(); + function handleLineClick(section: SectionState, clickedLine: number) { + const index = section.lines.indexOf(clickedLine); + if (index === -1) { + section.lines.push(clickedLine); + section.lines.sort((a, b) => a - b); + } else { + section.lines.splice(index, 1); + } } function startSelection( @@ -119,37 +215,100 @@ export const useOverrideGenerator = createSingletonComposable(() => { activeEditor.value = editor; fileName.value = file; unicodeVersion.value = version; - selectionStart.value = detectedStart; - selectionEnd.value = detectedEnd; + + sections.value = createInitialSectionStates(); + + const firstSection = sections.value[0]; + if (firstSection) { + firstSection.status = "active"; + activeSectionId.value = firstSection.id; + + const definition = getSectionDefinition(firstSection.id); + if (definition?.mode === "range") { + firstSection.range = { start: detectedStart, end: detectedEnd }; + } + } selectionChangeDisposable = window.onDidChangeTextEditorSelection(onSelectionChange); updateDecorations(); } - function setStart(line: number) { + function setActiveSection(sectionId: SectionId) { if (mode.value !== "selecting") return; - selectionStart.value = line; - if (selectionEnd.value !== null && line > selectionEnd.value) { - selectionEnd.value = line; + + const prevSection = activeSection.value; + if (prevSection && prevSection.status === "active") { + prevSection.status = "pending"; + } + + clearDecorations(); + + const section = sections.value.find((s) => s.id === sectionId); + if (section) { + section.status = "active"; + activeSectionId.value = sectionId; + clickMode.value = "set-start"; + updateDecorations(); } - updateDecorations(); } - function setEnd(line: number) { - if (mode.value !== "selecting") return; - selectionEnd.value = line; - if (selectionStart.value !== null && line < selectionStart.value) { - selectionStart.value = line; + function confirmActiveSection(): boolean { + const section = activeSection.value; + const definition = activeSectionDefinition.value; + + if (!section || !definition) return false; + + if (!isSectionSelectionValid(section, definition)) { + return false; } - updateDecorations(); + + section.status = "done"; + clearDecorations(); + + const nextPending = pendingSections.value[0]; + if (nextPending) { + nextPending.status = "active"; + activeSectionId.value = nextPending.id; + clickMode.value = "set-start"; + updateDecorations(); + } else { + activeSectionId.value = null; + } + + return true; } - function confirm(): ParserOverride | null { - if (!isValid.value || !currentOverride.value) { - return null; + function skipActiveSection(): boolean { + const section = activeSection.value; + if (!section) return false; + + section.range = null; + section.lines = []; + section.status = "done"; + clearDecorations(); + + const nextPending = pendingSections.value[0]; + if (nextPending) { + nextPending.status = "active"; + activeSectionId.value = nextPending.id; + clickMode.value = "set-start"; + updateDecorations(); + } else { + activeSectionId.value = null; + } + + return true; + } + + function confirmAll(): ParserOverride | null { + if (!allSectionsDone.value && activeSection.value) { + const confirmed = confirmActiveSection(); + if (!confirmed) return null; } + if (!allSectionsDone.value) return null; + const result = currentOverride.value; cleanup(); return result; @@ -161,10 +320,10 @@ export const useOverrideGenerator = createSingletonComposable(() => { function cleanup() { mode.value = "idle"; - selectionStart.value = null; - selectionEnd.value = null; fileName.value = null; unicodeVersion.value = null; + sections.value = []; + activeSectionId.value = null; clearDecorations(); activeEditor.value = null; @@ -178,34 +337,50 @@ export const useOverrideGenerator = createSingletonComposable(() => { const editor = activeEditor.value; if (!editor) return; - const start = selectionStart.value; - const end = selectionEnd.value; + const section = activeSection.value; + const definition = activeSectionDefinition.value; - if (start === null || end === null) { + if (!section || !definition) { clearDecorations(); return; } const document = editor.document; - const actualStart = Math.min(start, end); - const actualEnd = Math.max(start, end); + const decorations = getDecorationsForSection(section.id); + + if (definition.mode === "range" && section.range) { + const { start, end } = section.range; + const actualStart = Math.min(start, end); + const actualEnd = Math.max(start, end); + + const highlightRanges = []; + for (let i = actualStart; i <= actualEnd; i++) { + if (i < document.lineCount) { + highlightRanges.push(document.lineAt(i).range); + } + } + editor.setDecorations(decorations.highlight, highlightRanges); - const highlightRanges = []; - for (let i = actualStart; i <= actualEnd; i++) { - if (i < document.lineCount) { - highlightRanges.push(document.lineAt(i).range); + if (actualStart < document.lineCount) { + editor.setDecorations(decorations.start, [document.lineAt(actualStart).range]); } - } - editor.setDecorations(headingHighlightDecoration, highlightRanges); - if (actualStart < document.lineCount) { - editor.setDecorations(selectionStartDecoration, [document.lineAt(actualStart).range]); - } + if (actualEnd < document.lineCount && actualEnd !== actualStart) { + editor.setDecorations(decorations.end, [document.lineAt(actualEnd).range]); + } else { + editor.setDecorations(decorations.end, []); + } + + editor.setDecorations(decorations.line, []); + } else if (definition.mode === "lines") { + const lineRanges = section.lines + .filter((line) => line < document.lineCount) + .map((line) => document.lineAt(line).range); - if (actualEnd < document.lineCount && actualEnd !== actualStart) { - editor.setDecorations(selectionEndDecoration, [document.lineAt(actualEnd).range]); - } else if (actualEnd === actualStart) { - editor.setDecorations(selectionEndDecoration, []); + editor.setDecorations(decorations.line, lineRanges); + editor.setDecorations(decorations.highlight, []); + editor.setDecorations(decorations.start, []); + editor.setDecorations(decorations.end, []); } } @@ -213,26 +388,36 @@ export const useOverrideGenerator = createSingletonComposable(() => { const editor = activeEditor.value; if (!editor) return; - editor.setDecorations(headingHighlightDecoration, []); - editor.setDecorations(selectionStartDecoration, []); - editor.setDecorations(selectionEndDecoration, []); + for (const sectionDef of SECTION_DEFINITIONS) { + const decorations = decorationCache.get(sectionDef.id); + if (decorations) { + editor.setDecorations(decorations.highlight, []); + editor.setDecorations(decorations.start, []); + editor.setDecorations(decorations.end, []); + editor.setDecorations(decorations.line, []); + } + } } return { mode, clickMode, - selectionStart, - selectionEnd, fileName, unicodeVersion, - currentPosition, - isValid, + sections, + activeSectionId, + activeSection, + activeSectionDefinition, + pendingSections, + doneSections, + allSectionsDone, currentOverride, overrideJson, startSelection, - setStart, - setEnd, - confirm, + setActiveSection, + confirmActiveSection, + skipActiveSection, + confirmAll, cancel, updateDecorations, }; diff --git a/vscode/src/composables/useSelectionView.ts b/vscode/src/composables/useSelectionView.ts index b7a9f733f..8bdac74f8 100644 --- a/vscode/src/composables/useSelectionView.ts +++ b/vscode/src/composables/useSelectionView.ts @@ -1,18 +1,90 @@ +import type { SectionId, SectionState } from "../lib/override-schema"; import { computed, createSingletonComposable, useWebviewView, watchEffect } from "reactive-vscode"; +import { env, window } from "vscode"; +import { getSectionDefinition, SECTION_DEFINITIONS } from "../lib/override-schema"; import { useOverrideGenerator } from "./useOverrideGenerator"; +function getStatusIcon(status: SectionState["status"]): string { + switch (status) { + case "done": return "✅"; + case "active": return "➜"; + case "pending": return "○"; + } +} + +function getStatusLabel(status: SectionState["status"]): string { + switch (status) { + case "done": return "Complete"; + case "active": return "Active"; + case "pending": return "Pending"; + } +} + +function generateSectionListHtml( + sections: SectionState[], + activeSectionId: string | null, +): string { + return sections.map((section) => { + const definition = getSectionDefinition(section.id); + if (!definition) return ""; + + const isActive = section.id === activeSectionId; + const activeClass = isActive ? "active" : ""; + const statusClass = section.status; + + let selectionInfo = ""; + if (definition.mode === "range" && section.range) { + selectionInfo = `Lines ${section.range.start}-${section.range.end}`; + } else if (definition.mode === "lines" && section.lines.length > 0) { + selectionInfo = `${section.lines.length} line(s)`; + } else { + selectionInfo = "Not set"; + } + + return ` +
+
+ ${getStatusIcon(section.status)} + + ${getStatusLabel(section.status)} +
+
+ ${definition.mode} + ${selectionInfo} +
+
+ `; + }).join(""); +} + function generateSelectionHtml( fileName: string, version: string, - start: number, - end: number, - jsonPreview: string, + sections: SectionState[], + activeSectionId: string | null, clickMode: string, + jsonPreview: string, + pendingCount: number, + doneCount: number, + allDone: boolean, ): string { - const nextClickAction = clickMode === "set-start" ? "Set START line" : "Set END line"; - const nextClickColor = clickMode === "set-start" ? "rgba(76, 175, 80, 0.8)" : "rgba(244, 67, 54, 0.8)"; + const activeSection = sections.find((s) => s.id === activeSectionId); + const activeDefinition = activeSectionId ? getSectionDefinition(activeSectionId as "heading") : null; + + let nextClickAction = ""; + let nextClickColor = "var(--vscode-descriptionForeground)"; + + if (activeSection && activeDefinition) { + if (activeDefinition.mode === "range") { + nextClickAction = clickMode === "set-start" ? "Set START line" : "Set END line"; + nextClickColor = activeDefinition.markerColor; + } else { + nextClickAction = "Toggle line selection"; + nextClickColor = activeDefinition.markerColor; + } + } - return /* html */ ` + return ` @@ -52,6 +124,27 @@ function generateSelectionHtml( margin-top: 4px; } + .progress-bar { + display: flex; + gap: 4px; + margin-top: 8px; + } + + .progress-segment { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--vscode-panel-border); + } + + .progress-segment.done { + background: rgba(76, 175, 80, 0.8); + } + + .progress-segment.active { + background: rgba(255, 213, 79, 0.8); + } + .section { margin-bottom: 16px; } @@ -65,6 +158,69 @@ function generateSelectionHtml( letter-spacing: 0.5px; } + .section-item { + padding: 10px; + background: var(--vscode-editor-background); + border-radius: 4px; + margin-bottom: 6px; + border-left: 3px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + } + + .section-item:hover { + background: var(--vscode-list-hoverBackground); + } + + .section-item.active { + border-left-color: rgba(255, 213, 79, 0.8); + background: var(--vscode-list-activeSelectionBackground); + } + + .section-item.done { + border-left-color: rgba(76, 175, 80, 0.6); + } + + .section-header { + display: flex; + align-items: center; + gap: 8px; + } + + .status-icon { + font-size: 0.9em; + } + + .section-label { + font-weight: 500; + flex: 1; + } + + .status-label { + font-size: 0.8em; + color: var(--vscode-descriptionForeground); + } + + .section-details { + display: flex; + gap: 8px; + margin-top: 6px; + font-size: 0.85em; + } + + .mode-badge { + padding: 2px 6px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 3px; + font-size: 0.75em; + text-transform: uppercase; + } + + .selection-info { + color: var(--vscode-descriptionForeground); + } + .info-row { display: flex; justify-content: space-between; @@ -83,14 +239,17 @@ function generateSelectionHtml( font-family: var(--vscode-editor-font-family); } - .selection-badge { - display: inline-block; - padding: 4px 8px; - background: rgba(255, 213, 79, 0.3); - border: 1px solid rgba(255, 213, 79, 0.6); - border-radius: 4px; - font-family: var(--vscode-editor-font-family); + .click-hint { font-size: 0.9em; + padding: 10px; + background: var(--vscode-editor-background); + border-radius: 4px; + border-left: 3px solid ${nextClickColor}; + margin-bottom: 12px; + } + + .click-hint strong { + color: ${nextClickColor}; } .json-preview { @@ -102,23 +261,10 @@ function generateSelectionHtml( font-size: 0.85em; white-space: pre; overflow-x: auto; - max-height: 200px; + max-height: 150px; overflow-y: auto; } - .click-hint { - font-size: 0.9em; - padding: 10px; - background: var(--vscode-editor-background); - border-radius: 4px; - border-left: 3px solid ${nextClickColor}; - margin-bottom: 12px; - } - - .click-hint strong { - color: ${nextClickColor}; - } - .hint { font-size: 0.8em; color: var(--vscode-descriptionForeground); @@ -128,17 +274,69 @@ function generateSelectionHtml( border-radius: 4px; border-left: 3px solid var(--vscode-textLink-foreground); } + + .action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--vscode-panel-border); + } + + .action-btn { + flex: 1; + min-width: 80px; + padding: 8px 12px; + border: none; + border-radius: 4px; + font-family: var(--vscode-font-family); + font-size: 0.9em; + cursor: pointer; + transition: opacity 0.15s ease; + } + + .action-btn:hover { + opacity: 0.9; + } + + .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .action-btn.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + } + + .action-btn.secondary { + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + } + + .action-btn.danger { + background: var(--vscode-errorForeground); + color: var(--vscode-editor-background); + }
-
Heading Selection Mode
-
Click lines in the editor to adjust range
+
Override Selection
+
${doneCount}/${sections.length} sections complete
+
+ ${sections.map((s) => `
`).join("")} +
+ ${activeSection + ? `
Next click: ${nextClickAction}
+ ` + : ""}
Target File
@@ -153,19 +351,8 @@ function generateSelectionHtml(
-
Current Selection
-
- Start Line - ${start} -
-
- End Line - ${end} -
-
- Total Lines - ${end - start + 1} -
+
Sections
+ ${generateSectionListHtml(sections, activeSectionId)}
@@ -173,16 +360,59 @@ function generateSelectionHtml(
${jsonPreview}
-
- Run "Generate Parser Override" again to confirm or cancel. +
+ ${allDone + ? `` + : ` + + + ` + } +
+ + `.trim(); } function generateEmptyHtml(): string { - return /* html */ ` + return ` @@ -230,23 +460,63 @@ export const useSelectionView = createSingletonComposable(() => { const file = generator.fileName.value ?? "Unknown"; const version = generator.unicodeVersion.value ?? "Unknown"; - const start = generator.selectionStart.value ?? 0; - const end = generator.selectionEnd.value ?? 0; + const sections = generator.sections.value; + const activeSectionId = generator.activeSectionId.value; const json = generator.overrideJson.value ?? "{}"; + const pendingCount = generator.pendingSections.value.length; + const doneCount = generator.doneSections.value.length; + const allDone = generator.allSectionsDone.value; - return generateSelectionHtml(file, version, start, end, json, generator.clickMode.value); + return generateSelectionHtml( + file, + version, + sections, + activeSectionId, + generator.clickMode.value, + json, + pendingCount, + doneCount, + allDone, + ); }); useWebviewView("ucd:selection", html, { webviewOptions: { - enableScripts: false, + enableScripts: true, + }, + onDidReceiveMessage: async (message: { type: string; sectionId?: string }) => { + switch (message.type) { + case "selectSection": + if (message.sectionId) { + generator.setActiveSection(message.sectionId as "heading"); + } + break; + case "confirmSection": + generator.confirmActiveSection(); + break; + case "skipSection": + generator.skipActiveSection(); + break; + case "finish": { + const override = generator.confirmAll(); + if (override) { + const json = JSON.stringify(override, null, 2); + await env.clipboard.writeText(json); + void window.showInformationMessage("Override JSON copied to clipboard!"); + } + break; + } + case "cancel": + generator.cancel(); + break; + } }, }); watchEffect(() => { if (generator.mode.value === "selecting") { - void generator.selectionStart.value; - void generator.selectionEnd.value; + void generator.sections.value; + void generator.activeSectionId.value; void generator.clickMode.value; } }); diff --git a/vscode/src/lib/override-schema.ts b/vscode/src/lib/override-schema.ts index 71b4d6254..326c49d0d 100644 --- a/vscode/src/lib/override-schema.ts +++ b/vscode/src/lib/override-schema.ts @@ -15,12 +15,17 @@ export interface HeadingOverride { position?: Position; } +export interface AnnotationsOverride { + lines?: number[]; +} + export interface ParserOverride { $schema?: string; version: 1; fileName: string; unicodeVersion: string; heading?: HeadingOverride; + annotations?: AnnotationsOverride; } export function isValidPosition(position: Position): boolean { @@ -49,3 +54,64 @@ export function createParserOverride( export function serializeOverride(override: ParserOverride): string { return JSON.stringify(override, null, 2); } + +export type SelectionMode = "range" | "lines"; + +export type SectionStatus = "pending" | "active" | "done"; + +export type SectionId = "heading" | "annotations"; + +export interface SectionDefinition { + id: SectionId; + label: string; + description: string; + mode: SelectionMode; + highlightColor: string; + markerColor: string; +} + +export interface SectionState { + id: SectionId; + status: SectionStatus; + range: Position | null; + lines: number[]; +} + +export const SECTION_DEFINITIONS: readonly SectionDefinition[] = [ + { + id: "heading", + label: "Heading", + description: "File header and metadata lines", + mode: "range", + highlightColor: "rgba(255, 213, 79, 0.3)", + markerColor: "rgba(255, 213, 79, 0.8)", + }, + { + id: "annotations", + label: "Annotations", + description: "Annotation comment lines", + mode: "lines", + highlightColor: "rgba(100, 181, 246, 0.3)", + markerColor: "rgba(100, 181, 246, 0.8)", + }, +] as const; + +export function getSectionDefinition(id: SectionId): SectionDefinition | undefined { + return SECTION_DEFINITIONS.find((def) => def.id === id); +} + +export function createInitialSectionStates(): SectionState[] { + return SECTION_DEFINITIONS.map((def) => ({ + id: def.id, + status: "pending" as SectionStatus, + range: null, + lines: [], + })); +} + +export function isSectionSelectionValid(state: SectionState, definition: SectionDefinition): boolean { + if (definition.mode === "range") { + return state.range !== null && isValidPosition(state.range); + } + return state.lines.length > 0; +} From 57ef526b39d1f957d095d85123892b76e4dac4f4 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 15 Jan 2026 21:04:28 +0100 Subject: [PATCH 2/2] refactor(vscode): simplify path sanitization in openOnUnicode command --- vscode/src/commands/open-on-unicode.ts | 32 +++----------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/vscode/src/commands/open-on-unicode.ts b/vscode/src/commands/open-on-unicode.ts index b95ad12e5..7481094d7 100644 --- a/vscode/src/commands/open-on-unicode.ts +++ b/vscode/src/commands/open-on-unicode.ts @@ -1,5 +1,6 @@ import type { TreeViewNode } from "reactive-vscode"; import type { UCDTreeItem } from "../composables/useUCDExplorer"; +import { resolveSafePath } from "@ucdjs/path-utils"; import { executeCommand, useCommand } from "reactive-vscode"; import { Uri } from "vscode"; import * as Meta from "../generated/meta"; @@ -19,35 +20,8 @@ export function useOpenOnUnicodeCommand() { return; } - // Sanitize path to prevent directory traversal attacks - const rawPath = treeViewOrUri.path; - - // Normalize the path and check for traversal attempts - // Use a simple approach: split, filter out dangerous segments, rejoin - const segments = rawPath.split("/").filter((segment) => { - // Block empty segments, current dir refs, and parent dir refs - return segment !== "" && segment !== "." && segment !== ".."; - }); - - // If no valid segments remain, block the request - if (segments.length === 0) { - logger.error("Invalid path provided to openOnUnicode command: path is empty or invalid."); - return; - } - - // Check if any segment still contains traversal patterns (encoded or otherwise) - const hasTraversal = segments.some((segment) => { - const decoded = decodeURIComponent(segment); - return decoded === ".." || decoded === "." || decoded.includes("../") || decoded.includes("..\\"); - }); - - if (hasTraversal) { - logger.error("Invalid path provided to openOnUnicode command: path traversal detected."); - return; - } - - const sanitizedPath = segments.join("/"); - executeCommand("vscode.open", Uri.parse(`https://unicode.org/Public/${sanitizedPath}`)); + const resolvedPath = resolveSafePath("/Public/", treeViewOrUri.path); + executeCommand("vscode.open", Uri.parse(`https://unicode.org/${resolvedPath}`)); return; }