feat(KB-029): add file browser for viewing and editing task files
- Add server-side file service with list, read, and write operations - Add REST API endpoints for file operations with tests - Add client-side API functions and React hooks for file browsing - Create FileBrowser, FileEditor, and FileBrowserModal components - Add CodeMirror 6 for syntax-highlighted file editing - Integrate Files tab into TaskDetailModal - Add comprehensive file browser styles and responsive design
This commit is contained in:
5
.changeset/add-file-browser-editor.md
Normal file
5
.changeset/add-file-browser-editor.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@kb/dashboard": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add file browser and editor to task detail modal. Browse worktree files and edit with CodeMirror 6.
|
||||||
1
packages/dashboard/KB-012/newfile.txt
Normal file
1
packages/dashboard/KB-012/newfile.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
New content
|
||||||
1
packages/dashboard/KB-013/existing.txt
Normal file
1
packages/dashboard/KB-013/existing.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
New content
|
||||||
1
packages/dashboard/KB-016/nonexistent/dir/file.txt
Normal file
1
packages/dashboard/KB-016/nonexistent/dir/file.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
content
|
||||||
1
packages/dashboard/KB-017/existing-dir
Normal file
1
packages/dashboard/KB-017/existing-dir
Normal file
@@ -0,0 +1 @@
|
|||||||
|
content
|
||||||
@@ -84,6 +84,24 @@ The dashboard includes a fully interactive shell terminal for executing commands
|
|||||||
- Push current branch
|
- Push current branch
|
||||||
- View operation results and error states
|
- View operation results and error states
|
||||||
|
|
||||||
|
### File Browser
|
||||||
|
Browse and edit task worktree files directly from the task detail modal:
|
||||||
|
|
||||||
|
- **Files Tab**: Available when a task has a worktree assigned
|
||||||
|
- **File Tree**: Navigate directories with breadcrumb-style path display
|
||||||
|
- **Code Editor**: Edit files with syntax highlighting powered by CodeMirror 6
|
||||||
|
- Supports TypeScript, JavaScript, JSON, CSS, Markdown, and more
|
||||||
|
- One-dark theme matching the dashboard
|
||||||
|
- Auto-detects language from file extension
|
||||||
|
- **Safety Features**:
|
||||||
|
- Path traversal prevention (blocks `..` patterns)
|
||||||
|
- Binary file detection (prevents editing images, executables, etc.)
|
||||||
|
- 1MB file size limit
|
||||||
|
- Unsaved change indicators
|
||||||
|
- **Keyboard Shortcuts**:
|
||||||
|
- `Ctrl/Cmd+S` to save
|
||||||
|
- `Escape` to close
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, notifications, and appearance
|
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, notifications, and appearance
|
||||||
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
|
||||||
|
|||||||
@@ -532,3 +532,52 @@ export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIG
|
|||||||
export function getTerminalStreamUrl(sessionId: string): string {
|
export function getTerminalStreamUrl(sessionId: string): string {
|
||||||
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
|
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- File Browser API ---
|
||||||
|
|
||||||
|
/** File node in directory listing */
|
||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size?: number;
|
||||||
|
mtime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File listing response */
|
||||||
|
export interface FileListResponse {
|
||||||
|
path: string;
|
||||||
|
entries: FileNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File content response */
|
||||||
|
export interface FileContentResponse {
|
||||||
|
content: string;
|
||||||
|
mtime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save file response */
|
||||||
|
export interface SaveFileResponse {
|
||||||
|
success: true;
|
||||||
|
mtime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List files in task directory */
|
||||||
|
export function fetchFileList(taskId: string, path?: string): Promise<FileListResponse> {
|
||||||
|
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||||
|
return api<FileListResponse>(`/tasks/${taskId}/files${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch file content */
|
||||||
|
export function fetchFileContent(taskId: string, filePath: string): Promise<FileContentResponse> {
|
||||||
|
return api<FileContentResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save file content */
|
||||||
|
export function saveFileContent(taskId: string, filePath: string, content: string): Promise<SaveFileResponse> {
|
||||||
|
return api<SaveFileResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
113
packages/dashboard/app/components/FileBrowser.tsx
Normal file
113
packages/dashboard/app/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Folder, File, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import type { FileNode } from "../api";
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
entries: FileNode[];
|
||||||
|
currentPath: string;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number): string {
|
||||||
|
if (bytes === undefined) return "";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(mtime?: string): string {
|
||||||
|
if (!mtime) return "";
|
||||||
|
const date = new Date(mtime);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowser({
|
||||||
|
entries,
|
||||||
|
currentPath,
|
||||||
|
onSelectFile,
|
||||||
|
onNavigate,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: FileBrowserProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="file-browser-loading">
|
||||||
|
<Loader2 className="spin" size={24} />
|
||||||
|
<span>Loading files...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="file-browser-error">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button className="btn btn-sm" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-browser">
|
||||||
|
<div className="file-browser-header">
|
||||||
|
{currentPath !== "." && (
|
||||||
|
<button
|
||||||
|
className="file-browser-up"
|
||||||
|
onClick={() => {
|
||||||
|
const parts = currentPath.split("/").filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
onNavigate(parts.length === 0 ? "." : parts.join("/"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} style={{ transform: "rotate(-90deg)" }} />
|
||||||
|
Up one level
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="file-browser-path">{currentPath === "." ? "Root" : currentPath}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-browser-list">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="file-browser-empty">(empty directory)</div>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.name}
|
||||||
|
className={`file-node file-node--${entry.type}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
onNavigate(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
|
||||||
|
} else {
|
||||||
|
onSelectFile(currentPath === "." ? entry.name : `${currentPath}/${entry.name}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="file-node-icon">
|
||||||
|
{entry.type === "directory" ? (
|
||||||
|
<Folder size={16} />
|
||||||
|
) : (
|
||||||
|
<File size={16} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="file-node-name">{entry.name}</div>
|
||||||
|
{entry.type === "file" && entry.size !== undefined && (
|
||||||
|
<div className="file-node-size">{formatBytes(entry.size)}</div>
|
||||||
|
)}
|
||||||
|
{entry.mtime && (
|
||||||
|
<div className="file-node-time">{formatTime(entry.mtime)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
packages/dashboard/app/components/FileBrowserModal.tsx
Normal file
170
packages/dashboard/app/components/FileBrowserModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { X, Save, RotateCcw, Folder } from "lucide-react";
|
||||||
|
import { useFileBrowser } from "../hooks/useFileBrowser";
|
||||||
|
import { useFileEditor } from "../hooks/useFileEditor";
|
||||||
|
import { FileBrowser } from "./FileBrowser";
|
||||||
|
import { FileEditor } from "./FileEditor";
|
||||||
|
|
||||||
|
interface FileBrowserModalProps {
|
||||||
|
taskId: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserModal({ taskId, worktreePath, onClose }: FileBrowserModalProps) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
entries,
|
||||||
|
currentPath,
|
||||||
|
setPath,
|
||||||
|
loading: browserLoading,
|
||||||
|
error: browserError,
|
||||||
|
refresh,
|
||||||
|
} = useFileBrowser(taskId, true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
setContent,
|
||||||
|
originalContent,
|
||||||
|
loading: editorLoading,
|
||||||
|
saving,
|
||||||
|
error: editorError,
|
||||||
|
save,
|
||||||
|
hasChanges,
|
||||||
|
mtime,
|
||||||
|
} = useFileEditor(taskId, selectedFile, true);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hasChanges && !saving) {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose, hasChanges, saving, save]);
|
||||||
|
|
||||||
|
const handleSelectFile = useCallback((path: string) => {
|
||||||
|
setSelectedFile(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setContent(originalContent);
|
||||||
|
}, [originalContent, setContent]);
|
||||||
|
|
||||||
|
const formatFileSize = (content: string): string => {
|
||||||
|
const bytes = new Blob([content]).size;
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay open" onClick={onClose}>
|
||||||
|
<div className="modal file-browser-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="file-browser-header-title">
|
||||||
|
<Folder size={18} />
|
||||||
|
<span>Files</span>
|
||||||
|
{selectedFile && (
|
||||||
|
<span className="file-browser-header-path">
|
||||||
|
{currentPath === "." ? "" : currentPath + "/"}
|
||||||
|
{selectedFile}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="modal-close" onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-browser-body">
|
||||||
|
<div className="file-browser-sidebar">
|
||||||
|
<FileBrowser
|
||||||
|
entries={entries}
|
||||||
|
currentPath={currentPath}
|
||||||
|
onSelectFile={handleSelectFile}
|
||||||
|
onNavigate={setPath}
|
||||||
|
loading={browserLoading}
|
||||||
|
error={browserError}
|
||||||
|
onRetry={refresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-browser-content">
|
||||||
|
{selectedFile ? (
|
||||||
|
<>
|
||||||
|
<div className="file-browser-toolbar">
|
||||||
|
<div className="file-browser-file-info">
|
||||||
|
{selectedFile}
|
||||||
|
{mtime && (
|
||||||
|
<span className="file-browser-mtime">
|
||||||
|
Modified: {new Date(mtime).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{editorLoading && (
|
||||||
|
<span className="file-browser-loading">Loading...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="file-browser-actions">
|
||||||
|
{hasChanges && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorError && (
|
||||||
|
<div className="file-browser-error-banner">{editorError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="file-editor-wrapper">
|
||||||
|
<FileEditor
|
||||||
|
content={content}
|
||||||
|
onChange={setContent}
|
||||||
|
filePath={selectedFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-browser-footer">
|
||||||
|
<span>{formatFileSize(content)}</span>
|
||||||
|
{hasChanges && <span className="file-browser-unsaved">Unsaved changes</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="file-browser-placeholder">
|
||||||
|
<Folder size={48} opacity={0.3} />
|
||||||
|
<p>Select a file to edit</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
packages/dashboard/app/components/FileEditor.tsx
Normal file
133
packages/dashboard/app/components/FileEditor.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { basicSetup } from "@codemirror/basic-setup";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { css } from "@codemirror/lang-css";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
|
||||||
|
interface FileEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLanguage(filePath?: string) {
|
||||||
|
if (!filePath) return null;
|
||||||
|
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case ".ts":
|
||||||
|
case ".tsx":
|
||||||
|
case ".js":
|
||||||
|
case ".jsx":
|
||||||
|
case ".mjs":
|
||||||
|
case ".cjs":
|
||||||
|
return javascript({ typescript: ext === ".ts" || ext === ".tsx" });
|
||||||
|
case ".json":
|
||||||
|
case ".jsonc":
|
||||||
|
return json();
|
||||||
|
case ".md":
|
||||||
|
case ".markdown":
|
||||||
|
return markdown();
|
||||||
|
case ".css":
|
||||||
|
case ".scss":
|
||||||
|
case ".sass":
|
||||||
|
case ".less":
|
||||||
|
return css();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileEditor({ content, onChange, readOnly, filePath }: FileEditorProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
// Create editor on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || viewRef.current) return;
|
||||||
|
|
||||||
|
const language = detectLanguage(filePath);
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
oneDark,
|
||||||
|
EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
fontSize: "13px",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
fontFamily: '"SF Mono", Monaco, Consolas, monospace',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
EditorState.readOnly.of(readOnly ?? false),
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: "Mod-s",
|
||||||
|
run: () => {
|
||||||
|
// Save is handled by parent component
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (language) {
|
||||||
|
extensions.push(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: content,
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: editorRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewRef.current = view;
|
||||||
|
setIsReady(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, []); // Only run once on mount
|
||||||
|
|
||||||
|
// Update content when it changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !isReady) return;
|
||||||
|
|
||||||
|
const currentContent = view.state.doc.toString();
|
||||||
|
if (content !== currentContent) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [content, isReady]);
|
||||||
|
|
||||||
|
// Update readOnly when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !isReady) return;
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
effects: EditorState.readOnly.reconfigure(readOnly ?? false),
|
||||||
|
});
|
||||||
|
}, [readOnly, isReady]);
|
||||||
|
|
||||||
|
return <div ref={editorRef} className="file-editor-container" />;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { SteeringTab } from "./SteeringTab";
|
|||||||
import { ModelSelectorTab } from "./ModelSelectorTab";
|
import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||||
import { PrSection } from "./PrSection";
|
import { PrSection } from "./PrSection";
|
||||||
import { SpecEditor } from "./SpecEditor";
|
import { SpecEditor } from "./SpecEditor";
|
||||||
|
import { FileBrowserModal } from "./FileBrowserModal";
|
||||||
|
|
||||||
function getStepStatusColor(status: string): string {
|
function getStepStatusColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -78,7 +79,7 @@ export function TaskDetailModal({
|
|||||||
addToast,
|
addToast,
|
||||||
githubTokenConfigured,
|
githubTokenConfigured,
|
||||||
}: TaskDetailModalProps) {
|
}: TaskDetailModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec">("definition");
|
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec" | "files">("definition");
|
||||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||||
@@ -443,8 +444,23 @@ export function TaskDetailModal({
|
|||||||
>
|
>
|
||||||
Spec
|
Spec
|
||||||
</button>
|
</button>
|
||||||
|
{task.worktree && (
|
||||||
|
<button
|
||||||
|
className={`detail-tab${activeTab === "files" ? " detail-tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("files")}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "spec" ? (
|
{activeTab === "files" ? (
|
||||||
|
<FileBrowserModal
|
||||||
|
taskId={task.id}
|
||||||
|
worktreePath={task.worktree}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setActiveTab("definition")}
|
||||||
|
/>
|
||||||
|
) : activeTab === "spec" ? (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<SpecEditor
|
<SpecEditor
|
||||||
content={task.prompt || ""}
|
content={task.prompt || ""}
|
||||||
|
|||||||
84
packages/dashboard/app/hooks/useFileBrowser.ts
Normal file
84
packages/dashboard/app/hooks/useFileBrowser.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { FileNode, FileListResponse } from "../api";
|
||||||
|
import { fetchFileList } from "../api";
|
||||||
|
|
||||||
|
interface UseFileBrowserReturn {
|
||||||
|
entries: FileNode[];
|
||||||
|
currentPath: string;
|
||||||
|
setPath: (path: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for browsing files in a task directory.
|
||||||
|
*
|
||||||
|
* @param taskId - The task ID to browse
|
||||||
|
* @param enabled - Whether to enable fetching (e.g., when tab is active)
|
||||||
|
* @returns File browser state and controls
|
||||||
|
*/
|
||||||
|
export function useFileBrowser(taskId: string, enabled: boolean): UseFileBrowserReturn {
|
||||||
|
const [entries, setEntries] = useState<FileNode[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>(".");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPath = useCallback((path: string) => {
|
||||||
|
setCurrentPath(path);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: FileListResponse = await fetchFileList(
|
||||||
|
taskId,
|
||||||
|
currentPath === "." ? undefined : currentPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setEntries(response.entries);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.message || "Failed to load files");
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFiles();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [taskId, currentPath, enabled, refreshKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
currentPath,
|
||||||
|
setPath,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
packages/dashboard/app/hooks/useFileEditor.ts
Normal file
120
packages/dashboard/app/hooks/useFileEditor.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { FileContentResponse, SaveFileResponse } from "../api";
|
||||||
|
import { fetchFileContent, saveFileContent } from "../api";
|
||||||
|
|
||||||
|
interface UseFileEditorReturn {
|
||||||
|
content: string;
|
||||||
|
setContent: (content: string) => void;
|
||||||
|
originalContent: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
save: () => Promise<void>;
|
||||||
|
hasChanges: boolean;
|
||||||
|
mtime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for editing a file in a task directory.
|
||||||
|
*
|
||||||
|
* @param taskId - The task ID
|
||||||
|
* @param filePath - The file path to edit (null if no file selected)
|
||||||
|
* @param enabled - Whether to enable loading (e.g., when editor is visible)
|
||||||
|
* @returns File editor state and controls
|
||||||
|
*/
|
||||||
|
export function useFileEditor(
|
||||||
|
taskId: string,
|
||||||
|
filePath: string | null,
|
||||||
|
enabled: boolean
|
||||||
|
): UseFileEditorReturn {
|
||||||
|
const [content, setContentState] = useState<string>("");
|
||||||
|
const [originalContent, setOriginalContent] = useState<string>("");
|
||||||
|
const [mtime, setMtime] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setContent = useCallback((newContent: string) => {
|
||||||
|
setContentState(newContent);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load file content when filePath changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !taskId || !filePath) {
|
||||||
|
setContentState("");
|
||||||
|
setOriginalContent("");
|
||||||
|
setMtime(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadFile() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: FileContentResponse = await fetchFileContent(taskId, filePath);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setContentState(response.content);
|
||||||
|
setOriginalContent(response.content);
|
||||||
|
setMtime(response.mtime);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.message || "Failed to load file");
|
||||||
|
setContentState("");
|
||||||
|
setOriginalContent("");
|
||||||
|
setMtime(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFile();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [taskId, filePath, enabled]);
|
||||||
|
|
||||||
|
const hasChanges = content !== originalContent;
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!taskId || !filePath || !hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: SaveFileResponse = await saveFileContent(taskId, filePath, content);
|
||||||
|
setOriginalContent(content);
|
||||||
|
setMtime(response.mtime);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to save file");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [taskId, filePath, content, hasChanges]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
setContent,
|
||||||
|
originalContent,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
save,
|
||||||
|
hasChanges,
|
||||||
|
mtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,14 @@
|
|||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/basic-setup": "^0.20.0",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.2",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"@codemirror/view": "^6.36.4",
|
||||||
"@kb/core": "workspace:*",
|
"@kb/core": "workspace:*",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
|||||||
384
packages/dashboard/src/file-service.ts
Normal file
384
packages/dashboard/src/file-service.ts
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import { join, resolve, relative, dirname } from "node:path";
|
||||||
|
import { readdir, readFile as fsReadFile, writeFile as fsWriteFile, stat } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import type { TaskStore } from "@kb/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File node type representing a file or directory entry.
|
||||||
|
*/
|
||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size?: number;
|
||||||
|
mtime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File listing response.
|
||||||
|
*/
|
||||||
|
export interface FileListResponse {
|
||||||
|
path: string;
|
||||||
|
entries: FileNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File content response.
|
||||||
|
*/
|
||||||
|
export interface FileContentResponse {
|
||||||
|
content: string;
|
||||||
|
mtime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save file response.
|
||||||
|
*/
|
||||||
|
export interface SaveFileResponse {
|
||||||
|
success: true;
|
||||||
|
mtime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum file size for reading/writing (1MB).
|
||||||
|
*/
|
||||||
|
export const MAX_FILE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for file service operations.
|
||||||
|
*/
|
||||||
|
export class FileServiceError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FileServiceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text file extensions set.
|
||||||
|
*/
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
".txt", ".md", ".markdown",
|
||||||
|
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||||
|
".json", ".jsonc",
|
||||||
|
".css", ".scss", ".sass", ".less",
|
||||||
|
".html", ".htm", ".xml", ".svg",
|
||||||
|
".yaml", ".yml",
|
||||||
|
".toml",
|
||||||
|
".ini", ".cfg", ".conf", ".config",
|
||||||
|
".sh", ".bash", ".zsh", ".fish",
|
||||||
|
".py", ".rb", ".php", ".pl", ".perl",
|
||||||
|
".java", ".kt", ".scala", ".groovy",
|
||||||
|
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
|
||||||
|
".cs", ".fs", ".fsx",
|
||||||
|
".go", ".rs", ".swift",
|
||||||
|
".sql",
|
||||||
|
".dockerfile", ".env", ".envrc", ".nvmrc",
|
||||||
|
".gitignore", ".gitattributes", ".editorconfig",
|
||||||
|
".lock", ".log",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary file extensions set.
|
||||||
|
*/
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
|
||||||
|
".exe", ".dll", ".so", ".dylib",
|
||||||
|
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
||||||
|
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||||
|
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
|
||||||
|
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
||||||
|
".wasm", ".bin",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a binary file based on extension.
|
||||||
|
*/
|
||||||
|
function isBinaryFile(filename: string): boolean {
|
||||||
|
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
||||||
|
if (BINARY_EXTENSIONS.has(ext)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base path for a task's files.
|
||||||
|
* Returns the worktree path if it exists, otherwise the task directory.
|
||||||
|
*/
|
||||||
|
async function getTaskBasePath(store: TaskStore, taskId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const task = await store.getTask(taskId);
|
||||||
|
// Use worktree if available and exists
|
||||||
|
if (task.worktree && existsSync(task.worktree)) {
|
||||||
|
return resolve(task.worktree);
|
||||||
|
}
|
||||||
|
// Fall back to task directory
|
||||||
|
const rootDir = store.getRootDir();
|
||||||
|
return resolve(join(rootDir, ".kb", "tasks", taskId));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT" || err.message?.includes("not found")) {
|
||||||
|
throw new FileServiceError(`Task ${taskId} not found`, "ENOTASK");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and resolve a file path to ensure it stays within the allowed directory.
|
||||||
|
* Prevents directory traversal attacks.
|
||||||
|
*/
|
||||||
|
function validatePath(basePath: string, filePath: string): string {
|
||||||
|
// Reject paths with null bytes
|
||||||
|
if (filePath.includes("\0")) {
|
||||||
|
throw new FileServiceError(`Access denied: Invalid characters in path`, "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode URL-encoded characters for security check
|
||||||
|
const decodedPath = decodeURIComponent(filePath);
|
||||||
|
|
||||||
|
// Reject absolute paths
|
||||||
|
if (decodedPath.startsWith("/") || decodedPath.match(/^[a-zA-Z]:/)) {
|
||||||
|
throw new FileServiceError(`Access denied: Absolute paths not allowed`, "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the path against base path
|
||||||
|
const resolvedBase = resolve(basePath);
|
||||||
|
const resolvedPath = resolve(join(resolvedBase, decodedPath));
|
||||||
|
|
||||||
|
// Ensure the resolved path is within the base path
|
||||||
|
const relativePath = relative(resolvedBase, resolvedPath);
|
||||||
|
|
||||||
|
// Check for traversal - path starts with .. or is outside base
|
||||||
|
if (relativePath.startsWith("..") || relativePath.startsWith("../") || relativePath === "..") {
|
||||||
|
throw new FileServiceError(`Access denied: Path traversal detected`, "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: ensure resolved path actually starts with base
|
||||||
|
if (!resolvedPath.startsWith(resolvedBase)) {
|
||||||
|
throw new FileServiceError(`Access denied: Path outside allowed directory`, "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a task directory or subdirectory.
|
||||||
|
*
|
||||||
|
* @param store - The TaskStore instance
|
||||||
|
* @param taskId - The task ID
|
||||||
|
* @param subPath - Optional relative path within the task directory
|
||||||
|
* @returns File listing response with entries sorted (dirs first, then files alphabetically)
|
||||||
|
* @throws FileServiceError on validation or filesystem errors
|
||||||
|
*/
|
||||||
|
export async function listFiles(
|
||||||
|
store: TaskStore,
|
||||||
|
taskId: string,
|
||||||
|
subPath?: string,
|
||||||
|
): Promise<FileListResponse> {
|
||||||
|
const taskBase = await getTaskBasePath(store, taskId);
|
||||||
|
const targetPath = subPath ? validatePath(taskBase, subPath) : taskBase;
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await stat(targetPath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new FileServiceError(`Not a directory: ${subPath || "."}`, "ENOTDIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
||||||
|
const fileNodes: FileNode[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Skip hidden files and directories
|
||||||
|
if (entry.name.startsWith(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = join(targetPath, entry.name);
|
||||||
|
const entryStats = await stat(entryPath);
|
||||||
|
|
||||||
|
fileNodes.push({
|
||||||
|
name: entry.name,
|
||||||
|
type: entry.isDirectory() ? "directory" : "file",
|
||||||
|
size: entry.isFile() ? entryStats.size : undefined,
|
||||||
|
mtime: entryStats.mtime.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: directories first, then files, both alphabetically
|
||||||
|
fileNodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === "directory" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate relative path from task base
|
||||||
|
const relativeBase = relative(taskBase, targetPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: relativeBase || ".",
|
||||||
|
entries: fileNodes,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`Directory not found: ${subPath || "."}`, "ENOENT");
|
||||||
|
}
|
||||||
|
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||||
|
throw new FileServiceError(`Permission denied: ${subPath || "."}`, "EACCES");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file contents from a task directory.
|
||||||
|
*
|
||||||
|
* @param store - The TaskStore instance
|
||||||
|
* @param taskId - The task ID
|
||||||
|
* @param filePath - The relative file path
|
||||||
|
* @returns File content response with content, mtime, and size
|
||||||
|
* @throws FileServiceError on validation or filesystem errors
|
||||||
|
*/
|
||||||
|
export async function readFile(
|
||||||
|
store: TaskStore,
|
||||||
|
taskId: string,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<FileContentResponse> {
|
||||||
|
if (!filePath) {
|
||||||
|
throw new FileServiceError("File path is required", "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskBase = await getTaskBasePath(store, taskId);
|
||||||
|
const resolvedPath = validatePath(taskBase, filePath);
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await stat(resolvedPath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new FileServiceError(`Not a file: ${filePath}`, "EISDIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
|
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a binary file
|
||||||
|
const basename = filePath.split("/").pop() || filePath;
|
||||||
|
if (isBinaryFile(basename)) {
|
||||||
|
throw new FileServiceError(`Binary file, cannot edit: ${filePath}`, "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fsReadFile(resolvedPath, "utf-8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
mtime: stats.mtime.toISOString(),
|
||||||
|
size: stats.size,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`File not found: ${filePath}`, "ENOENT");
|
||||||
|
}
|
||||||
|
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||||
|
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write file contents to a task directory.
|
||||||
|
*
|
||||||
|
* @param store - The TaskStore instance
|
||||||
|
* @param taskId - The task ID
|
||||||
|
* @param filePath - The relative file path
|
||||||
|
* @param content - The content to write
|
||||||
|
* @returns Save file response with success, mtime, and size
|
||||||
|
* @throws FileServiceError on validation or filesystem errors
|
||||||
|
*/
|
||||||
|
export async function writeFile(
|
||||||
|
store: TaskStore,
|
||||||
|
taskId: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<SaveFileResponse> {
|
||||||
|
if (!filePath) {
|
||||||
|
throw new FileServiceError("File path is required", "EINVAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content size
|
||||||
|
const contentBytes = Buffer.byteLength(content, "utf-8");
|
||||||
|
if (contentBytes > MAX_FILE_SIZE) {
|
||||||
|
throw new FileServiceError(`Content too large: ${contentBytes} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskBase = await getTaskBasePath(store, taskId);
|
||||||
|
const resolvedPath = validatePath(taskBase, filePath);
|
||||||
|
|
||||||
|
// Check if target is a directory
|
||||||
|
try {
|
||||||
|
const stats = await stat(resolvedPath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
throw new FileServiceError(`Cannot write to directory: ${filePath}`, "EISDIR");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// File doesn't exist, that's fine for writing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if parent directory exists
|
||||||
|
const parentDir = dirname(resolvedPath);
|
||||||
|
try {
|
||||||
|
const parentStats = await stat(parentDir);
|
||||||
|
if (!parentStats.isDirectory()) {
|
||||||
|
throw new FileServiceError(`Parent is not a directory: ${filePath}`, "ENOENT");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsWriteFile(resolvedPath, content, "utf-8");
|
||||||
|
|
||||||
|
const stats = await stat(resolvedPath);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mtime: stats.mtime.toISOString(),
|
||||||
|
size: stats.size,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
throw new FileServiceError(`Parent directory does not exist: ${filePath}`, "ENOENT");
|
||||||
|
}
|
||||||
|
if (err.code === "EACCES" || err.code === "EPERM") {
|
||||||
|
throw new FileServiceError(`Permission denied: ${filePath}`, "EACCES");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2380,4 +2380,174 @@ describe("Git Management endpoints", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── File API tests ────────────────────────────────────────────────────
|
||||||
|
describe("File API endpoints", () => {
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMockStore({
|
||||||
|
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /tasks/:id/files", () => {
|
||||||
|
it("returns 404 for non-existent task", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockRejectedValue({ code: "ENOENT" });
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-NONEXISTENT/files");
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when task directory does not exist", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files");
|
||||||
|
// Will fail because task directory doesn't exist
|
||||||
|
expect(res.status === 404 || res.status === 500).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts path query parameter", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files?path=src");
|
||||||
|
// Directory won't exist, but endpoint should process the query param
|
||||||
|
expect(res.status === 404 || res.status === 500).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /tasks/:id/files/:filepath", () => {
|
||||||
|
it("returns 404 for non-existent file", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files/nonexistent.txt");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for empty filepath", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files/");
|
||||||
|
// Empty path should result in error
|
||||||
|
expect(res.status === 400 || res.status === 404).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 415 for binary files", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png");
|
||||||
|
expect([415, 404, 500]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects path traversal attempts", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(buildApp(), "/api/tasks/KB-001/files/../etc/passwd");
|
||||||
|
expect([400, 404, 500]).toContain(res.status);
|
||||||
|
if (res.body?.error) {
|
||||||
|
expect(res.body.error).toContain("traversal");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /tasks/:id/files/:filepath", () => {
|
||||||
|
it("requires content in body", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/files/test.txt",
|
||||||
|
JSON.stringify({}),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("content is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string content", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/files/test.txt",
|
||||||
|
JSON.stringify({ content: 123 }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for non-existent parent directory", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/files/nonexistent/dir/file.txt",
|
||||||
|
JSON.stringify({ content: "test" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects path traversal in write", async () => {
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: "KB-001",
|
||||||
|
worktree: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/tasks/KB-001/files/../../../etc/passwd",
|
||||||
|
JSON.stringify({ content: "evil" }),
|
||||||
|
{ "Content-Type": "application/json" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([400, 404, 500]).toContain(res.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
|
|||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
||||||
import { terminalSessionManager } from "./terminal.js";
|
import { terminalSessionManager } from "./terminal.js";
|
||||||
|
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
||||||
@@ -1809,6 +1810,89 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── File API Routes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/:id/files
|
||||||
|
* List files in task directory (or worktree if available).
|
||||||
|
* Query param: ?path=relative/path for subdirectory navigation.
|
||||||
|
* Returns: { path: string; entries: FileNode[] }
|
||||||
|
*/
|
||||||
|
router.get("/tasks/:id/files", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path: subPath } = req.query;
|
||||||
|
const result = await listFiles(store, req.params.id, typeof subPath === "string" ? subPath : undefined);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof FileServiceError) {
|
||||||
|
const status = err.code === "ENOTASK" ? 404
|
||||||
|
: err.code === "ENOENT" ? 404
|
||||||
|
: err.code === "EACCES" ? 403
|
||||||
|
: 400;
|
||||||
|
res.status(status).json({ error: err.message, code: err.code });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message || "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tasks/:id/files/:filepath
|
||||||
|
* Read file contents.
|
||||||
|
* Returns: { content: string; mtime: string; size: number }
|
||||||
|
*/
|
||||||
|
router.get("/tasks/:id/files/{*filepath}", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||||
|
const result = await readFile(store, req.params.id, filePath);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof FileServiceError) {
|
||||||
|
const status = err.code === "ENOENT" ? 404
|
||||||
|
: err.code === "ENOTASK" ? 404
|
||||||
|
: err.code === "EACCES" ? 403
|
||||||
|
: err.code === "ETOOLARGE" ? 413
|
||||||
|
: err.code === "EINVAL" && err.message.includes("Binary file") ? 415
|
||||||
|
: 400;
|
||||||
|
res.status(status).json({ error: err.message, code: err.code });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message || "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tasks/:id/files/:filepath
|
||||||
|
* Write file contents.
|
||||||
|
* Body: { content: string }
|
||||||
|
* Returns: { success: true; mtime: string; size: number }
|
||||||
|
*/
|
||||||
|
router.post("/tasks/:id/files/{*filepath}", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
res.status(400).json({ error: "content is required and must be a string" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await writeFile(store, req.params.id, filePath, content);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof FileServiceError) {
|
||||||
|
const status = err.code === "ENOENT" ? 404
|
||||||
|
: err.code === "ENOTASK" ? 404
|
||||||
|
: err.code === "EACCES" ? 403
|
||||||
|
: err.code === "ETOOLARGE" ? 413
|
||||||
|
: 400;
|
||||||
|
res.status(status).json({ error: err.message, code: err.code });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message || "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
322
pnpm-lock.yaml
generated
322
pnpm-lock.yaml
generated
@@ -81,6 +81,30 @@ importers:
|
|||||||
|
|
||||||
packages/dashboard:
|
packages/dashboard:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@codemirror/basic-setup':
|
||||||
|
specifier: ^0.20.0
|
||||||
|
version: 0.20.0
|
||||||
|
'@codemirror/lang-css':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1
|
||||||
|
'@codemirror/lang-javascript':
|
||||||
|
specifier: ^6.2.3
|
||||||
|
version: 6.2.5
|
||||||
|
'@codemirror/lang-json':
|
||||||
|
specifier: ^6.0.1
|
||||||
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-markdown':
|
||||||
|
specifier: ^6.3.2
|
||||||
|
version: 6.5.0
|
||||||
|
'@codemirror/state':
|
||||||
|
specifier: ^6.5.2
|
||||||
|
version: 6.6.0
|
||||||
|
'@codemirror/theme-one-dark':
|
||||||
|
specifier: ^6.1.2
|
||||||
|
version: 6.1.3
|
||||||
|
'@codemirror/view':
|
||||||
|
specifier: ^6.36.4
|
||||||
|
version: 6.40.0
|
||||||
'@kb/core':
|
'@kb/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
@@ -493,6 +517,64 @@ packages:
|
|||||||
'@changesets/write@0.4.0':
|
'@changesets/write@0.4.0':
|
||||||
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
|
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
|
||||||
|
|
||||||
|
'@codemirror/autocomplete@0.20.3':
|
||||||
|
resolution: {integrity: sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==}
|
||||||
|
|
||||||
|
'@codemirror/autocomplete@6.20.1':
|
||||||
|
resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==}
|
||||||
|
|
||||||
|
'@codemirror/basic-setup@0.20.0':
|
||||||
|
resolution: {integrity: sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==}
|
||||||
|
deprecated: In version 6.0, this package has been renamed to just 'codemirror'
|
||||||
|
|
||||||
|
'@codemirror/commands@0.20.0':
|
||||||
|
resolution: {integrity: sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==}
|
||||||
|
|
||||||
|
'@codemirror/lang-css@6.3.1':
|
||||||
|
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||||
|
|
||||||
|
'@codemirror/lang-html@6.4.11':
|
||||||
|
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-javascript@6.2.5':
|
||||||
|
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
|
||||||
|
|
||||||
|
'@codemirror/lang-json@6.0.2':
|
||||||
|
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
|
||||||
|
|
||||||
|
'@codemirror/language@0.20.2':
|
||||||
|
resolution: {integrity: sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==}
|
||||||
|
|
||||||
|
'@codemirror/language@6.12.3':
|
||||||
|
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
|
||||||
|
|
||||||
|
'@codemirror/lint@0.20.3':
|
||||||
|
resolution: {integrity: sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==}
|
||||||
|
|
||||||
|
'@codemirror/lint@6.9.5':
|
||||||
|
resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
|
||||||
|
|
||||||
|
'@codemirror/search@0.20.1':
|
||||||
|
resolution: {integrity: sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==}
|
||||||
|
|
||||||
|
'@codemirror/state@0.20.1':
|
||||||
|
resolution: {integrity: sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==}
|
||||||
|
|
||||||
|
'@codemirror/state@6.6.0':
|
||||||
|
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
|
||||||
|
|
||||||
|
'@codemirror/theme-one-dark@6.1.3':
|
||||||
|
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||||
|
|
||||||
|
'@codemirror/view@0.20.7':
|
||||||
|
resolution: {integrity: sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==}
|
||||||
|
|
||||||
|
'@codemirror/view@6.40.0':
|
||||||
|
resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==}
|
||||||
|
|
||||||
'@csstools/color-helpers@6.0.2':
|
'@csstools/color-helpers@6.0.2':
|
||||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
@@ -892,12 +974,48 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@lezer/common@0.16.1':
|
||||||
|
resolution: {integrity: sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==}
|
||||||
|
|
||||||
|
'@lezer/common@1.5.1':
|
||||||
|
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||||
|
|
||||||
|
'@lezer/css@1.3.3':
|
||||||
|
resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==}
|
||||||
|
|
||||||
|
'@lezer/highlight@0.16.0':
|
||||||
|
resolution: {integrity: sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==}
|
||||||
|
|
||||||
|
'@lezer/highlight@1.2.3':
|
||||||
|
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||||
|
|
||||||
|
'@lezer/html@1.3.13':
|
||||||
|
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||||
|
|
||||||
|
'@lezer/javascript@1.5.4':
|
||||||
|
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||||
|
|
||||||
|
'@lezer/json@1.0.3':
|
||||||
|
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
||||||
|
|
||||||
|
'@lezer/lr@0.16.3':
|
||||||
|
resolution: {integrity: sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==}
|
||||||
|
|
||||||
|
'@lezer/lr@1.4.8':
|
||||||
|
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.3':
|
||||||
|
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
|
||||||
|
|
||||||
'@manypkg/find-root@1.1.0':
|
'@manypkg/find-root@1.1.0':
|
||||||
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
||||||
|
|
||||||
'@manypkg/get-packages@1.1.3':
|
'@manypkg/get-packages@1.1.3':
|
||||||
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
|
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
|
||||||
|
|
||||||
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
|
|
||||||
'@mariozechner/clipboard-darwin-arm64@0.3.2':
|
'@mariozechner/clipboard-darwin-arm64@0.3.2':
|
||||||
resolution: {integrity: sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==}
|
resolution: {integrity: sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -1828,6 +1946,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
crelt@1.0.6:
|
||||||
|
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3169,6 +3290,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
style-mod@4.1.3:
|
||||||
|
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||||
|
|
||||||
@@ -3445,6 +3569,9 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8:
|
||||||
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4229,6 +4356,144 @@ snapshots:
|
|||||||
human-id: 4.1.3
|
human-id: 4.1.3
|
||||||
prettier: 2.8.8
|
prettier: 2.8.8
|
||||||
|
|
||||||
|
'@codemirror/autocomplete@0.20.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 0.20.2
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
'@lezer/common': 0.16.1
|
||||||
|
|
||||||
|
'@codemirror/autocomplete@6.20.1':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
|
||||||
|
'@codemirror/basic-setup@0.20.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 0.20.3
|
||||||
|
'@codemirror/commands': 0.20.0
|
||||||
|
'@codemirror/language': 0.20.2
|
||||||
|
'@codemirror/lint': 0.20.3
|
||||||
|
'@codemirror/search': 0.20.1
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
|
||||||
|
'@codemirror/commands@0.20.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 0.20.2
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
'@lezer/common': 0.16.1
|
||||||
|
|
||||||
|
'@codemirror/lang-css@6.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.1
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/css': 1.3.3
|
||||||
|
|
||||||
|
'@codemirror/lang-html@6.4.11':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.1
|
||||||
|
'@codemirror/lang-css': 6.3.1
|
||||||
|
'@codemirror/lang-javascript': 6.2.5
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/css': 1.3.3
|
||||||
|
'@lezer/html': 1.3.13
|
||||||
|
|
||||||
|
'@codemirror/lang-javascript@6.2.5':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.1
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/lint': 6.9.5
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/javascript': 1.5.4
|
||||||
|
|
||||||
|
'@codemirror/lang-json@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@lezer/json': 1.0.3
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.1
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/markdown': 1.6.3
|
||||||
|
|
||||||
|
'@codemirror/language@0.20.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
'@lezer/common': 0.16.1
|
||||||
|
'@lezer/highlight': 0.16.0
|
||||||
|
'@lezer/lr': 0.16.3
|
||||||
|
style-mod: 4.1.3
|
||||||
|
|
||||||
|
'@codemirror/language@6.12.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
style-mod: 4.1.3
|
||||||
|
|
||||||
|
'@codemirror/lint@0.20.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
crelt: 1.0.6
|
||||||
|
|
||||||
|
'@codemirror/lint@6.9.5':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
crelt: 1.0.6
|
||||||
|
|
||||||
|
'@codemirror/search@0.20.1':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
'@codemirror/view': 0.20.7
|
||||||
|
crelt: 1.0.6
|
||||||
|
|
||||||
|
'@codemirror/state@0.20.1': {}
|
||||||
|
|
||||||
|
'@codemirror/state@6.6.0':
|
||||||
|
dependencies:
|
||||||
|
'@marijn/find-cluster-break': 1.0.2
|
||||||
|
|
||||||
|
'@codemirror/theme-one-dark@6.1.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.3
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
'@codemirror/view': 6.40.0
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
|
||||||
|
'@codemirror/view@0.20.7':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 0.20.1
|
||||||
|
style-mod: 4.1.3
|
||||||
|
w3c-keyname: 2.2.8
|
||||||
|
|
||||||
|
'@codemirror/view@6.40.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/state': 6.6.0
|
||||||
|
crelt: 1.0.6
|
||||||
|
style-mod: 4.1.3
|
||||||
|
w3c-keyname: 2.2.8
|
||||||
|
|
||||||
'@csstools/color-helpers@6.0.2': {}
|
'@csstools/color-helpers@6.0.2': {}
|
||||||
|
|
||||||
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||||
@@ -4459,6 +4724,55 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@lezer/common@0.16.1': {}
|
||||||
|
|
||||||
|
'@lezer/common@1.5.1': {}
|
||||||
|
|
||||||
|
'@lezer/css@1.3.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/highlight@0.16.0':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 0.16.1
|
||||||
|
|
||||||
|
'@lezer/highlight@1.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
|
||||||
|
'@lezer/html@1.3.13':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/javascript@1.5.4':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/json@1.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/lr@0.16.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 0.16.1
|
||||||
|
|
||||||
|
'@lezer/lr@1.4.8':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
|
||||||
'@manypkg/find-root@1.1.0':
|
'@manypkg/find-root@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.2
|
||||||
@@ -4475,6 +4789,8 @@ snapshots:
|
|||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
read-yaml-file: 1.1.0
|
read-yaml-file: 1.1.0
|
||||||
|
|
||||||
|
'@marijn/find-cluster-break@1.0.2': {}
|
||||||
|
|
||||||
'@mariozechner/clipboard-darwin-arm64@0.3.2':
|
'@mariozechner/clipboard-darwin-arm64@0.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5536,6 +5852,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
crelt@1.0.6: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -7220,6 +7538,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
|
|
||||||
|
style-mod@4.1.3: {}
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
style-to-object: 1.0.14
|
style-to-object: 1.0.14
|
||||||
@@ -7586,6 +7906,8 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user