mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-23 08:48:28 +00:00
Add drag-and-drop from preview directory to WaveAI panel (#2502)
Enables dragging files from preview directory listings directly into the
WaveAI panel for analysis.
## Changes
**Modified `frontend/app/aipanel/aipanel.tsx`:**
- Added `useDrop` hook to accept `FILE_ITEM` drag type from preview
directory
- Implemented `handleFileItemDrop` to:
- Read file content via `RpcApi.FileReadCommand` using the remote URI
- Convert base64 data to browser `File` object with proper MIME type
- Validate and add to panel using existing `model.addFile()` flow
- Integrated with existing drag overlay for visual feedback
- Rejects directories with appropriate error messaging
## Implementation
```typescript
const handleFileItemDrop = useCallback(
async (draggedFile: DraggedFile) => {
if (draggedFile.isDir) {
model.setError("Cannot add directories to Wave AI. Please select a file.");
return;
}
const fileData = await RpcApi.FileReadCommand(TabRpcClient, {
info: { path: draggedFile.uri }
}, null);
const bytes = new Uint8Array(atob(fileData.data64).split('').map(c => c.charCodeAt(0)));
const file = new File([bytes], draggedFile.relName, {
type: fileData.info?.mimetype || "application/octet-stream"
});
// Existing validation and addFile flow
await model.addFile(file);
},
[model]
);
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: "FILE_ITEM",
drop: handleFileItemDrop,
collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop() })
}), [handleFileItemDrop]);
```
No changes required to preview directory—it already exports `FILE_ITEM`
drag items. Works independently from native file system drag-and-drop.
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
This commit is contained in:
parent
a19cb6f300
commit
bf0312f69d
4 changed files with 301 additions and 39 deletions
|
|
@ -1,6 +1,13 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
const TextFileLimit = 200 * 1024; // 200KB
|
||||
const PdfLimit = 5 * 1024 * 1024; // 5MB
|
||||
const ImageLimit = 10 * 1024 * 1024; // 10MB
|
||||
const ImagePreviewSize = 128;
|
||||
const ImagePreviewWebPQuality = 0.8;
|
||||
const ImageMaxEdge = 4096;
|
||||
|
||||
export const isAcceptableFile = (file: File): boolean => {
|
||||
const acceptableTypes = [
|
||||
// Images
|
||||
|
|
@ -34,10 +41,15 @@ export const isAcceptableFile = (file: File): boolean => {
|
|||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
const acceptableExtensions = [
|
||||
"txt",
|
||||
"log",
|
||||
"md",
|
||||
"js",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"jsx",
|
||||
"ts",
|
||||
"mts",
|
||||
"cts",
|
||||
"tsx",
|
||||
"go",
|
||||
"py",
|
||||
|
|
@ -47,10 +59,15 @@ export const isAcceptableFile = (file: File): boolean => {
|
|||
"h",
|
||||
"hpp",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"jsonl",
|
||||
"ndjson",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
|
|
@ -69,9 +86,116 @@ export const isAcceptableFile = (file: File): boolean => {
|
|||
"clj",
|
||||
"ex",
|
||||
"exs",
|
||||
"ini",
|
||||
"toml",
|
||||
"conf",
|
||||
"cfg",
|
||||
"env",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"bazel",
|
||||
"bzl",
|
||||
"csv",
|
||||
"tsv",
|
||||
"properties",
|
||||
"ipynb",
|
||||
"rmd",
|
||||
"gradle",
|
||||
"groovy",
|
||||
"cmake",
|
||||
];
|
||||
|
||||
return extension ? acceptableExtensions.includes(extension) : false;
|
||||
if (extension && acceptableExtensions.includes(extension)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific filenames (case-insensitive)
|
||||
const fileName = file.name.toLowerCase();
|
||||
const acceptableFilenames = [
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"containerfile",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"go.work",
|
||||
"go.work.sum",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"composer.json",
|
||||
"composer.lock",
|
||||
"gemfile",
|
||||
"gemfile.lock",
|
||||
"podfile",
|
||||
"podfile.lock",
|
||||
"cargo.toml",
|
||||
"cargo.lock",
|
||||
"pipfile",
|
||||
"pipfile.lock",
|
||||
"requirements.txt",
|
||||
"setup.py",
|
||||
"pyproject.toml",
|
||||
"poetry.lock",
|
||||
"build.gradle",
|
||||
"settings.gradle",
|
||||
"pom.xml",
|
||||
"build.xml",
|
||||
"readme",
|
||||
"readme.md",
|
||||
"license",
|
||||
"license.md",
|
||||
"changelog",
|
||||
"changelog.md",
|
||||
"contributing",
|
||||
"contributing.md",
|
||||
"authors",
|
||||
"codeowners",
|
||||
"procfile",
|
||||
"jenkinsfile",
|
||||
"vagrantfile",
|
||||
"rakefile",
|
||||
"gruntfile.js",
|
||||
"gulpfile.js",
|
||||
"webpack.config.js",
|
||||
"rollup.config.js",
|
||||
"vite.config.js",
|
||||
"jest.config.js",
|
||||
"vitest.config.js",
|
||||
".dockerignore",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".gitmodules",
|
||||
".editorconfig",
|
||||
".eslintrc",
|
||||
".prettierrc",
|
||||
".pylintrc",
|
||||
".bashrc",
|
||||
".bash_profile",
|
||||
".bash_login",
|
||||
".bash_logout",
|
||||
".profile",
|
||||
".zshrc",
|
||||
".zprofile",
|
||||
".zshenv",
|
||||
".zlogin",
|
||||
".zlogout",
|
||||
".kshrc",
|
||||
".cshrc",
|
||||
".tcshrc",
|
||||
".xonshrc",
|
||||
".shrc",
|
||||
".aliases",
|
||||
".functions",
|
||||
".exports",
|
||||
".direnvrc",
|
||||
".vimrc",
|
||||
".gvimrc",
|
||||
];
|
||||
|
||||
return acceptableFilenames.includes(fileName);
|
||||
};
|
||||
|
||||
export const getFileIcon = (fileName: string, fileType: string): string => {
|
||||
|
|
@ -182,34 +306,30 @@ export interface FileSizeError {
|
|||
}
|
||||
|
||||
export const validateFileSize = (file: File): FileSizeError | null => {
|
||||
const TEXT_FILE_LIMIT = 200 * 1024; // 200KB
|
||||
const PDF_LIMIT = 5 * 1024 * 1024; // 5MB
|
||||
const IMAGE_LIMIT = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (file.size > IMAGE_LIMIT) {
|
||||
if (file.size > ImageLimit) {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
maxSize: IMAGE_LIMIT,
|
||||
maxSize: ImageLimit,
|
||||
fileType: "image",
|
||||
};
|
||||
}
|
||||
} else if (file.type === "application/pdf") {
|
||||
if (file.size > PDF_LIMIT) {
|
||||
if (file.size > PdfLimit) {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
maxSize: PDF_LIMIT,
|
||||
maxSize: PdfLimit,
|
||||
fileType: "pdf",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (file.size > TEXT_FILE_LIMIT) {
|
||||
if (file.size > TextFileLimit) {
|
||||
return {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
maxSize: TEXT_FILE_LIMIT,
|
||||
maxSize: TextFileLimit,
|
||||
fileType: "text",
|
||||
};
|
||||
}
|
||||
|
|
@ -218,6 +338,37 @@ export const validateFileSize = (file: File): FileSizeError | null => {
|
|||
return null;
|
||||
};
|
||||
|
||||
export const validateFileSizeFromInfo = (
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
mimeType: string
|
||||
): FileSizeError | null => {
|
||||
let maxSize: number;
|
||||
let fileType: "text" | "pdf" | "image";
|
||||
|
||||
if (mimeType.startsWith("image/")) {
|
||||
maxSize = ImageLimit;
|
||||
fileType = "image";
|
||||
} else if (mimeType === "application/pdf") {
|
||||
maxSize = PdfLimit;
|
||||
fileType = "pdf";
|
||||
} else {
|
||||
maxSize = TextFileLimit;
|
||||
fileType = "text";
|
||||
}
|
||||
|
||||
if (fileSize > maxSize) {
|
||||
return {
|
||||
fileName,
|
||||
fileSize,
|
||||
maxSize,
|
||||
fileType,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatFileSizeError = (error: FileSizeError): string => {
|
||||
const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file";
|
||||
return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`;
|
||||
|
|
@ -233,9 +384,6 @@ export const resizeImage = async (file: File): Promise<File> => {
|
|||
return file;
|
||||
}
|
||||
|
||||
const MAX_EDGE = 4096;
|
||||
const WEBP_QUALITY = 0.8;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
|
@ -246,7 +394,7 @@ export const resizeImage = async (file: File): Promise<File> => {
|
|||
let { width, height } = img;
|
||||
|
||||
// Check if resizing is needed
|
||||
if (width <= MAX_EDGE && height <= MAX_EDGE) {
|
||||
if (width <= ImageMaxEdge && height <= ImageMaxEdge) {
|
||||
// Image is already small enough, just try WebP conversion
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
|
|
@ -272,18 +420,18 @@ export const resizeImage = async (file: File): Promise<File> => {
|
|||
}
|
||||
},
|
||||
"image/webp",
|
||||
WEBP_QUALITY
|
||||
ImagePreviewWebPQuality
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
if (width > height) {
|
||||
height = Math.round((height * MAX_EDGE) / width);
|
||||
width = MAX_EDGE;
|
||||
height = Math.round((height * ImageMaxEdge) / width);
|
||||
width = ImageMaxEdge;
|
||||
} else {
|
||||
width = Math.round((width * MAX_EDGE) / height);
|
||||
height = MAX_EDGE;
|
||||
width = Math.round((width * ImageMaxEdge) / height);
|
||||
height = ImageMaxEdge;
|
||||
}
|
||||
|
||||
// Create canvas and resize
|
||||
|
|
@ -312,7 +460,7 @@ export const resizeImage = async (file: File): Promise<File> => {
|
|||
}
|
||||
},
|
||||
"image/webp",
|
||||
WEBP_QUALITY
|
||||
ImagePreviewWebPQuality
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -333,9 +481,6 @@ export const createImagePreview = async (file: File): Promise<string | null> =>
|
|||
return null;
|
||||
}
|
||||
|
||||
const PREVIEW_SIZE = 128;
|
||||
const WEBP_QUALITY = 0.8;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
|
@ -346,11 +491,11 @@ export const createImagePreview = async (file: File): Promise<string | null> =>
|
|||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
height = Math.round((height * PREVIEW_SIZE) / width);
|
||||
width = PREVIEW_SIZE;
|
||||
height = Math.round((height * ImagePreviewSize) / width);
|
||||
width = ImagePreviewSize;
|
||||
} else {
|
||||
width = Math.round((width * PREVIEW_SIZE) / height);
|
||||
height = PREVIEW_SIZE;
|
||||
width = Math.round((width * ImagePreviewSize) / height);
|
||||
height = ImagePreviewSize;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
|
|
@ -372,7 +517,7 @@ export const createImagePreview = async (file: File): Promise<string | null> =>
|
|||
}
|
||||
},
|
||||
"image/webp",
|
||||
WEBP_QUALITY
|
||||
ImagePreviewWebPQuality
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useChat } from "@ai-sdk/react";
|
|||
import { DefaultChatTransport } from "ai";
|
||||
import * as jotai from "jotai";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils";
|
||||
import { AIDroppedFiles } from "./aidroppedfiles";
|
||||
import { AIPanelHeader } from "./aipanelheader";
|
||||
|
|
@ -209,6 +210,7 @@ interface AIPanelProps {
|
|||
|
||||
const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isReactDndDragOver, setIsReactDndDragOver] = useState(false);
|
||||
const [initialLoadDone, setInitialLoadDone] = useState(false);
|
||||
const model = WaveAIModel.getInstance();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -320,27 +322,43 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
|||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const hasFiles = hasFilesDragged(e.dataTransfer);
|
||||
|
||||
// Only handle native file drags here, let react-dnd handle FILE_ITEM drags
|
||||
if (!hasFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const hasFiles = hasFilesDragged(e.dataTransfer);
|
||||
if (hasFiles && !isDragOver) {
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
} else if (!hasFiles && isDragOver) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
const hasFiles = hasFilesDragged(e.dataTransfer);
|
||||
|
||||
// Only handle native file drags here, let react-dnd handle FILE_ITEM drags
|
||||
if (!hasFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasFilesDragged(e.dataTransfer)) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
const hasFiles = hasFilesDragged(e.dataTransfer);
|
||||
|
||||
// Only handle native file drags here, let react-dnd handle FILE_ITEM drags
|
||||
if (!hasFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -355,6 +373,12 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
|||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
// Check if this is a FILE_ITEM drag from react-dnd
|
||||
// If so, let react-dnd handle it instead
|
||||
if (!e.dataTransfer.files.length) {
|
||||
return; // Let react-dnd handle FILE_ITEM drags
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
|
@ -381,6 +405,39 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleFileItemDrop = useCallback(
|
||||
(draggedFile: DraggedFile) => model.addFileFromRemoteUri(draggedFile),
|
||||
[model]
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: "FILE_ITEM",
|
||||
drop: handleFileItemDrop,
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[handleFileItemDrop]
|
||||
);
|
||||
|
||||
// Update drag over state for FILE_ITEM drags
|
||||
useEffect(() => {
|
||||
if (isOver && canDrop) {
|
||||
setIsReactDndDragOver(true);
|
||||
} else {
|
||||
setIsReactDndDragOver(false);
|
||||
}
|
||||
}, [isOver, canDrop]);
|
||||
|
||||
// Attach the drop ref to the container
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
drop(containerRef.current);
|
||||
}
|
||||
}, [drop]);
|
||||
|
||||
const handleFocusCapture = useCallback(
|
||||
(event: React.FocusEvent) => {
|
||||
// console.log("Wave AI focus capture", getElemAsStr(event.target));
|
||||
|
|
@ -453,7 +510,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
|||
"bg-gray-900 flex flex-col relative h-[calc(100%-4px)]",
|
||||
model.inBuilder ? "mt-0" : "mt-1",
|
||||
className,
|
||||
isDragOver && "bg-gray-800 border-accent",
|
||||
(isDragOver || isReactDndDragOver) && "bg-gray-800 border-accent",
|
||||
isFocused ? "border-2 border-accent" : "border-2 border-transparent"
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -469,7 +526,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
|
|||
onClick={handleClick}
|
||||
inert={!isPanelVisible ? true : undefined}
|
||||
>
|
||||
{isDragOver && <AIDragOverlay />}
|
||||
{(isDragOver || isReactDndDragOver) && <AIDragOverlay />}
|
||||
{showBlockMask && <AIBlockMask />}
|
||||
<AIPanelHeader onClose={onClose} model={model} onClearChat={handleClearChat} />
|
||||
<AIRateLimitStrip />
|
||||
|
|
|
|||
|
|
@ -16,10 +16,19 @@ import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|||
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
|
||||
import { BuilderFocusManager } from "@/builder/store/builder-focusmanager";
|
||||
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||
import { base64ToArrayBuffer } from "@/util/util";
|
||||
import { ChatStatus } from "ai";
|
||||
import * as jotai from "jotai";
|
||||
import type React from "react";
|
||||
import { createDataUrl, createImagePreview, normalizeMimeType, resizeImage } from "./ai-utils";
|
||||
import {
|
||||
createDataUrl,
|
||||
createImagePreview,
|
||||
formatFileSizeError,
|
||||
isAcceptableFile,
|
||||
normalizeMimeType,
|
||||
resizeImage,
|
||||
validateFileSizeFromInfo,
|
||||
} from "./ai-utils";
|
||||
import type { AIPanelInputRef } from "./aipanelinput";
|
||||
|
||||
export interface DroppedFile {
|
||||
|
|
@ -154,6 +163,54 @@ export class WaveAIModel {
|
|||
return droppedFile;
|
||||
}
|
||||
|
||||
async addFileFromRemoteUri(draggedFile: DraggedFile): Promise<void> {
|
||||
if (draggedFile.isDir) {
|
||||
this.setError("Cannot add directories to Wave AI. Please select a file.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null);
|
||||
if (fileInfo.notfound) {
|
||||
this.setError(`File not found: ${draggedFile.relName}`);
|
||||
return;
|
||||
}
|
||||
if (fileInfo.isdir) {
|
||||
this.setError("Cannot add directories to Wave AI. Please select a file.");
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeType = fileInfo.mimetype || "application/octet-stream";
|
||||
const fileSize = fileInfo.size || 0;
|
||||
const sizeError = validateFileSizeFromInfo(draggedFile.relName, fileSize, mimeType);
|
||||
if (sizeError) {
|
||||
this.setError(formatFileSizeError(sizeError));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null);
|
||||
if (!fileData.data64) {
|
||||
this.setError(`Failed to read file: ${draggedFile.relName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = base64ToArrayBuffer(fileData.data64);
|
||||
const file = new File([buffer], draggedFile.relName, { type: mimeType });
|
||||
if (!isAcceptableFile(file)) {
|
||||
this.setError(
|
||||
`File type not supported: ${draggedFile.relName}. Supported: images, PDFs, and text/code files.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.addFile(file);
|
||||
} catch (error) {
|
||||
console.error("Error handling FILE_ITEM drop:", error);
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
this.setError(`Failed to add file: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(fileId: string) {
|
||||
const currentFiles = globalStore.get(this.droppedFiles);
|
||||
const updatedFiles = currentFiles.filter((f) => f.id !== fileId);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const shellFileMap: Record<string, string> = {
|
|||
".profile": "shell",
|
||||
".zshrc": "shell",
|
||||
".zprofile": "shell",
|
||||
".zshenv": "shell",
|
||||
".zlogin": "shell",
|
||||
".zlogout": "shell",
|
||||
".kshrc": "shell",
|
||||
|
|
@ -31,6 +32,8 @@ export const shellFileMap: Record<string, string> = {
|
|||
".functions": "shell",
|
||||
".exports": "shell",
|
||||
".direnvrc": "shell",
|
||||
".vimrc": "shell",
|
||||
".gvimrc": "shell",
|
||||
};
|
||||
|
||||
function CodeEditPreview({ model }: SpecializedViewProps) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue