Conversation
…enu and implement their creation logic.
There was a problem hiding this comment.
Pull request overview
Adds a new /editor route that hosts a Monaco-based editor workspace with a file-tree sidebar, tabs, drag/drop, and context-menu actions, supported by new composables and UI primitives.
Changes:
- Introduces
EditorView+ wrapper view and wires it into the router at/editor. - Adds editor sidebar components (tree items, tabs, context menu) plus composables for selection, clipboard, platform shortcuts, and drag/drop state.
- Adds UI wrapper components for
reka-uiresizable panels and scroll areas; updates dependencies (modern-monaco,reka-ui,@vueuse/core).
Reviewed changes
Copilot reviewed 20 out of 22 changed files in this pull request and generated 18 comments.
Show a summary per file
| File | Description |
|---|---|
| src/views/Editor/EditorViewWrapper.vue | Wrapper to mount the new editor view. |
| src/views/Editor/EditorView.vue | Main editor page: workspace FS tree, tab persistence, drag/drop, clipboard operations. |
| src/types/FileTree.ts | Defines file tree node shape used across editor components. |
| src/router/index.ts | Adds /editor route entry. |
| src/composable/usePlatform.ts | OS-aware shortcut formatting (Mac symbols vs Ctrl/Alt/Shift). |
| src/composable/useFileSelection.ts | VS Code-style multi-selection behavior for the tree. |
| src/composable/useFileDragDrop.ts | Shared drag state + helpers for intra-tree drag/drop. |
| src/composable/useFileClipboard.ts | Internal cut/copy/paste state + “copy path” helpers. |
| src/composable/useEditorTabs.ts | Manages open/active/preview tabs and persistence hooks. |
| src/components/ui/scroll-area/* | New scroll-area wrappers around reka-ui. |
| src/components/ui/resizable/* | New resizable panel wrappers around reka-ui. |
| src/components/Editor/FolderContextMenu.vue | Context menu UI for file/folder/background actions. |
| src/components/Editor/FileTreeItem.vue | Recursive tree item renderer with drag/drop + inline create/rename. |
| src/components/Editor/EditorTabs.vue | Tab strip UI for open files. |
| src/components/Editor/EditorSidebar.vue | Sidebar container: selection, context menu wiring, keyboard shortcuts, root creation. |
| package.json / package-lock.json | Adds/bumps dependencies for Monaco + reka-ui/vueuse updates. |
| .gitignore | Ignores local “instructions” files. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const parts = oldPath.split('/'); | ||
| parts[parts.length - 1] = newName; |
There was a problem hiding this comment.
newName is inserted into the path via oldPath.split('/') / join('/') without validation. If the user enters / (or an empty segment), this becomes a move across directories rather than a rename and can produce invalid paths. Consider rejecting names containing / (and optionally .. / leading dots) before calling fs.rename.
| const parts = oldPath.split('/'); | |
| parts[parts.length - 1] = newName; | |
| const trimmedName = newName.trim(); | |
| if ( | |
| !trimmedName || | |
| trimmedName.includes('/') || | |
| trimmedName === '.' || | |
| trimmedName === '..' | |
| ) { | |
| console.error('Invalid new name for rename:', newName); | |
| return; | |
| } | |
| const parts = oldPath.split('/'); | |
| parts[parts.length - 1] = trimmedName; |
| import type { ScrollAreaRootProps } from "reka-ui" | ||
| import type { HTMLAttributes } from "vue" | ||
| import { reactiveOmit } from "@vueuse/core" | ||
| import { | ||
| ScrollAreaCorner, | ||
| ScrollAreaRoot, | ||
| ScrollAreaViewport, | ||
| } from "reka-ui" | ||
| import { cn } from "@/lib/utils" | ||
| import ScrollBar from "./ScrollBar.vue" | ||
|
|
||
| const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>() | ||
|
|
||
| const delegatedProps = reactiveOmit(props, "class") |
There was a problem hiding this comment.
The script block uses double quotes and omits semicolons, which conflicts with the repo’s Prettier config (.prettierrc.json enforces singleQuote and semi). Please run Prettier or update formatting here to avoid CI/lint churn.
| import type { ScrollAreaRootProps } from "reka-ui" | |
| import type { HTMLAttributes } from "vue" | |
| import { reactiveOmit } from "@vueuse/core" | |
| import { | |
| ScrollAreaCorner, | |
| ScrollAreaRoot, | |
| ScrollAreaViewport, | |
| } from "reka-ui" | |
| import { cn } from "@/lib/utils" | |
| import ScrollBar from "./ScrollBar.vue" | |
| const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>() | |
| const delegatedProps = reactiveOmit(props, "class") | |
| import type { ScrollAreaRootProps } from 'reka-ui'; | |
| import type { HTMLAttributes } from 'vue'; | |
| import { reactiveOmit } from '@vueuse/core'; | |
| import { | |
| ScrollAreaCorner, | |
| ScrollAreaRoot, | |
| ScrollAreaViewport, | |
| } from 'reka-ui'; | |
| import { cn } from '@/lib/utils'; | |
| import ScrollBar from './ScrollBar.vue'; | |
| const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>(); | |
| const delegatedProps = reactiveOmit(props, 'class'); |
| // ── Viewport-aware positioning ───────────────────────────────────── | ||
| const menuRef = ref<HTMLDivElement | null>(null); | ||
| const adjustedX = ref(props.x); | ||
| const adjustedY = ref(props.y); | ||
|
|
||
| onMounted(async () => { | ||
| await nextTick(); | ||
| if (!menuRef.value) return; | ||
|
|
||
| const rect = menuRef.value.getBoundingClientRect(); | ||
| const vw = window.innerWidth; | ||
| const vh = window.innerHeight; | ||
| const padding = 8; | ||
|
|
||
| // Flip left if menu overflows right edge | ||
| if (props.x + rect.width + padding > vw) { | ||
| adjustedX.value = Math.max(padding, props.x - rect.width); | ||
| } | ||
|
|
||
| // Flip up if menu overflows bottom edge | ||
| if (props.y + rect.height + padding > vh) { | ||
| adjustedY.value = Math.max(padding, props.y - rect.height); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Positioning is only calculated in onMounted() and adjustedX/adjustedY are initialized once from props.x/y. If the context menu is opened again while this component instance stays mounted (e.g., right-click a different spot while menu is already open), the menu won't reposition. Consider watching props.x/props.y (and re-running the overflow logic after nextTick) or keying the component by x,y,nodeType to force a remount.
| /** Whether the menu was opened on a specific file/folder node */ | ||
| const hasNode = props.nodeType !== null; | ||
|
|
There was a problem hiding this comment.
hasNode is derived from props.nodeType once at setup time, so it won’t update if nodeType changes while the menu stays mounted. Make this a computed(() => props.nodeType !== null) (or avoid caching) so the menu content matches the current props.
| async function handlePasteItems(targetPath: string, sourcePaths: string[], mode: 'cut' | 'copy') { | ||
| try { | ||
| const targetNode = findNode(tree.value, targetPath); | ||
| const targetDir = targetNode?.type === 'folder' | ||
| ? targetPath | ||
| : targetPath.substring(0, targetPath.lastIndexOf('/')) || '/'; | ||
|
|
||
| for (const srcPath of sourcePaths) { | ||
| const destPath = await resolveDestPath(srcPath, targetDir, mode); | ||
| if (!destPath) continue; | ||
|
|
||
| if (mode === 'cut') { | ||
| await workspace.fs.rename(srcPath, destPath); | ||
|
|
There was a problem hiding this comment.
handlePasteItems doesn’t guard against illegal moves like cutting a folder into itself / one of its descendants (or into the same parent). The drag/drop path uses isInvalidDrop, but paste currently doesn’t, so it can attempt invalid fs.rename operations. Reuse isInvalidDrop(srcPath, targetDir) for mode === 'cut' before calling rename.
| @click="emit('tab-click', path)" | ||
| @dblclick="emit('tab-click', path)" | ||
| @mousedown="handleMiddleClick($event, path)" |
There was a problem hiding this comment.
The @dblclick handler currently emits tab-click again, so double-clicking can't trigger a separate “pin” action. Emit a dedicated tab-dblclick event here (and keep single-click behavior unchanged).
| await workspace.fs.rename(oldPath, newPath); | ||
| await reloadTree(); | ||
|
|
||
| tabs.renameOpenFile(oldPath, newPath); | ||
| saveTabs(); | ||
|
|
||
| if (tabs.activeFile.value === newPath) { | ||
| await workspace.openTextDocument(newPath); |
There was a problem hiding this comment.
tabs.renameOpenFile(oldPath, newPath) only updates an exact tab path. If oldPath is a folder, any open files under that folder will keep stale paths after fs.rename, and the active tab could point to a non-existent document. Consider detecting folder renames and updating all openFilesList entries that start with oldPath + '/' (and the active file) to the corresponding new prefix.
| await workspace.fs.rename(oldPath, newPath); | |
| await reloadTree(); | |
| tabs.renameOpenFile(oldPath, newPath); | |
| saveTabs(); | |
| if (tabs.activeFile.value === newPath) { | |
| await workspace.openTextDocument(newPath); | |
| const previousActive = tabs.activeFile.value; | |
| await workspace.fs.rename(oldPath, newPath); | |
| await reloadTree(); | |
| // Update an exact open file match (file rename case) | |
| tabs.renameOpenFile(oldPath, newPath); | |
| // Additionally handle folder renames: update any open files under the folder | |
| const oldPrefix = oldPath + '/'; | |
| const newPrefix = newPath + '/'; | |
| const currentOpenFiles = tabs.openFilesList.value; | |
| const updatedOpenFiles = currentOpenFiles.map((openPath) => { | |
| if (openPath.startsWith(oldPrefix)) { | |
| return newPrefix + openPath.slice(oldPrefix.length); | |
| } | |
| return openPath; | |
| }); | |
| // Only assign back if something actually changed | |
| let openFilesChanged = false; | |
| if (updatedOpenFiles.length === currentOpenFiles.length) { | |
| for (let i = 0; i < updatedOpenFiles.length; i++) { | |
| if (updatedOpenFiles[i] !== currentOpenFiles[i]) { | |
| openFilesChanged = true; | |
| break; | |
| } | |
| } | |
| } | |
| if (openFilesChanged) { | |
| tabs.openFilesList.value = updatedOpenFiles; | |
| } | |
| // Update the active file if it was under the renamed folder | |
| if (tabs.activeFile.value && tabs.activeFile.value.startsWith(oldPrefix)) { | |
| tabs.activeFile.value = | |
| newPrefix + tabs.activeFile.value.slice(oldPrefix.length); | |
| } | |
| saveTabs(); | |
| // Reopen the active document if its path changed due to the rename | |
| if (tabs.activeFile.value && tabs.activeFile.value !== previousActive) { | |
| await workspace.openTextDocument(tabs.activeFile.value); |
| tabs.renameOpenFile(srcPath, destPath); | ||
| if (tabs.activeFile.value === destPath) { | ||
| await workspace.openTextDocument(destPath); | ||
| } |
There was a problem hiding this comment.
In the cut/move path, tabs.renameOpenFile(srcPath, destPath) only updates a single exact path. If srcPath is a folder, open tabs for files inside that folder won’t be updated after the move, leaving broken tab paths. Consider renaming all open paths with the srcPath + '/' prefix (and updating activeFile/previewFile if needed).
| tabs.renameOpenFile(srcPath, destPath); | |
| if (tabs.activeFile.value === destPath) { | |
| await workspace.openTextDocument(destPath); | |
| } | |
| const previousActive = tabs.activeFile.value; | |
| const openFiles = [...tabs.openFilesList.value]; | |
| for (const openPath of openFiles) { | |
| if (openPath === srcPath) { | |
| // Exact match: move to destPath | |
| tabs.renameOpenFile(openPath, destPath); | |
| } else if (openPath.startsWith(srcPath + '/')) { | |
| // Path inside moved folder: preserve relative suffix | |
| const suffix = openPath.slice(srcPath.length); // includes leading '/' | |
| const newPath = destPath + suffix; | |
| tabs.renameOpenFile(openPath, newPath); | |
| } | |
| } | |
| if (tabs.activeFile.value && tabs.activeFile.value !== previousActive) { | |
| await workspace.openTextDocument(tabs.activeFile.value); | |
| } |
| export { default as ResizableHandle } from "./ResizableHandle.vue" | ||
| export { default as ResizablePanelGroup } from "./ResizablePanelGroup.vue" | ||
| export { SplitterPanel as ResizablePanel } from "reka-ui" |
There was a problem hiding this comment.
This index file uses double quotes and omits semicolons, which conflicts with the repo’s Prettier config (.prettierrc.json has singleQuote: true, semi: true). Please format to match the existing components/ui/*/index.ts exports (e.g. src/components/ui/accordion/index.ts).
| export { default as ResizableHandle } from "./ResizableHandle.vue" | |
| export { default as ResizablePanelGroup } from "./ResizablePanelGroup.vue" | |
| export { SplitterPanel as ResizablePanel } from "reka-ui" | |
| export { default as ResizableHandle } from './ResizableHandle.vue'; | |
| export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'; | |
| export { SplitterPanel as ResizablePanel } from 'reka-ui'; |
| function handleContextNewFile() { | ||
| closeContextMenu(); | ||
| if (contextMenu.value?.node) { | ||
| // Create inside a specific folder | ||
| const folderPath = contextMenu.value.node.path; | ||
| emit('ensure-expand', folderPath); | ||
| nextTick(() => { creatingIn.value = { folderPath, type: 'file' }; }); | ||
| } else { | ||
| // Background right-click → create at root | ||
| startCreateFileAtRoot(); | ||
| } | ||
| } | ||
|
|
||
| function handleContextNewFolder() { | ||
| closeContextMenu(); | ||
| if (contextMenu.value?.node) { | ||
| const folderPath = contextMenu.value.node.path; | ||
| emit('ensure-expand', folderPath); | ||
| nextTick(() => { creatingIn.value = { folderPath, type: 'folder' }; }); | ||
| } else { | ||
| startCreateFolderAtRoot(); | ||
| } | ||
| } |
There was a problem hiding this comment.
handleContextNewFile / handleContextNewFolder call closeContextMenu() before checking contextMenu.value?.node, so contextMenu.value is always null and the folder-specific creation path will never run. Capture contextMenu.value?.node (or the folder path) into a local variable before closing, or close the menu after branching.
No description provided.