From 08a27ae45fe9a3c520e309b2e488ce0892f3c387 Mon Sep 17 00:00:00 2001 From: Corwin Marsh <7642548+corwinm@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:10:29 -0700 Subject: [PATCH 1/4] docs: Add basic copilot-instruciton.md --- .github/copilot-instructions.md | 110 ++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5a6671b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,110 @@ +# Oil.code Development Guide + +## Architecture Overview + +Oil.code is a VSCode extension that provides oil.nvim-like file system editing capabilities. The core architecture revolves around: + +- **Custom FileSystemProviders** (`src/providers/`) - Virtual file systems using `oil://` and `oil-preview://` schemes +- **State Management** (`src/state/`) - Centralized state for navigation history, edits, and preview +- **Command System** (`src/commands/`) - User actions like select, preview, navigation +- **Vim Integration** (`src/vim/`) - Dynamic keymap registration for VSCodeVim and vscode-neovim + +## Key Patterns + +### URI Schemes and Path Translation + +The extension uses custom URI schemes that must be translated to/from disk paths: + +```typescript +// oil://current-path → disk path +const diskPath = oilUriToDiskPath(uri); +// disk path → oil://path +const oilUri = updateOilUri(oilState, targetPath); +``` + +### State-Driven File Operations + +File operations are handled through state changes rather than direct filesystem calls: + +```typescript +// Pattern: Update state → Save document → onDidSaveTextDocument handler processes changes +oilState.editedPaths.set(currentPath, modifiedLines); +await document.save(); // Triggers actual filesystem operations +``` + +### Line Format Convention + +Files/directories are displayed with hidden identifiers: `/000 ../`, `/001 filename.txt`, `/002 folder/` + +- Extract actual names: `lineText.replace(/^\/\d{3} /, "")` +- Directory detection: `fileName.endsWith("/")` + +### Dynamic Extension Detection + +Vim keymap registration uses retry logic since extensions load asynchronously: + +```typescript +attemptRegisteringVimKeymaps( + MAX_EXTENSION_DETECTION_RETRIES, + EXTENSION_DETECTION_DELAY +); +``` + +## Testing Patterns + +Tests in `src/test/extension.test.ts` follow this structure: + +- **Setup**: `cleanupTestDir()`, stub `showWarningMessage` for modal dialogs +- **Actions**: Use `vscode.commands.executeCommand()` + `editor.edit()` +- **Assertions**: `waitForDocumentText()`, `assertProjectFileStructure()`, `assertSelectionOnLine()` +- **Timing**: Liberal use of `sleep()` for async operations + +### Critical Test Utilities + +- `waitForDocumentText(expectedLines)` - Wait for oil view to update +- `assertProjectFileStructure(structure)` - Verify actual filesystem changes +- `moveCursorToLine(editor, lineNumber)` - Position cursor for selections + +## Build & Development + +```bash +# Watch builds (run both for full development) +npm run watch:tsc # TypeScript compilation +npm run watch:esbuild # Bundle for extension + +# Testing +npm test # Run extension tests +``` + +### ESBuild Integration + +The extension uses a custom esbuild plugin to inline `.lua` files as text (see `esbuild.js`). Lua files in `src/vim/` are embedded for neovim integration. + +## Navigation & Editor Management + +### Document Transitions + +When navigating directories, the extension opens new documents and closes old ones rather than in-place URI changes (VSCode limitation). The `closeOldDocument()` function handles tab cleanup with timing delays. + +### Preview System + +- Two-column layout: main oil view (left) + preview (right) +- Preview uses separate `oil-preview://` scheme with readonly provider +- Cursor movement triggers preview updates when enabled + +## Common Gotchas + +1. **Async timing**: Many operations need `sleep()` delays for VSCode state consistency +2. **Modal dialogs**: Stub `vscode.window.showWarningMessage` in tests to avoid blocking +3. **Extension loading**: Vim extensions may not be available immediately at activation +4. **State persistence**: `editedPaths` tracks unsaved changes across navigation +5. **Path normalization**: Always use `removeTrailingSlash()` for consistent path handling + +## File Organization + +- `src/commands/` - User-facing command implementations +- `src/handlers/` - Event handlers (save, editor change) +- `src/providers/` - FileSystemProvider implementations +- `src/state/` - Global state management +- `src/utils/` - Path utilities, file operations, oil-specific helpers +- `src/vim/` - Vim extension integration and keymaps From 172ed9703cb002962b81a33bcd9066b984bbff3c Mon Sep 17 00:00:00 2001 From: Corwin Marsh <7642548+corwinm@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:10:49 -0700 Subject: [PATCH 2/4] test: Add test to cover moving and copy file --- src/test/extension.test.ts | 113 +++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 93b1d4e..a34c140 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -767,4 +767,117 @@ suite("oil.code", () => { ); }); }); + + test("Copy and move file in one action", async () => { + await vscode.commands.executeCommand("oil-code.open"); + await waitForDocumentText("/000 ../"); + + const editor = vscode.window.activeTextEditor; + assert.ok(editor, "No active editor"); + + // Create a target directory and source file + await editor.edit((editBuilder) => { + editBuilder.insert( + new vscode.Position(1, 0), + `${newline}target-dir/${newline}source-file.md` + ); + }); + + await saveFile(); + + // Wait for file content to update + await waitForDocumentText([ + "/000 ../", + "/001 target-dir/", + "/002 source-file.md", + ]); + + // Add content to the source file + const filePosition = new vscode.Position(2, 0); + editor.selection = new vscode.Selection(filePosition, filePosition); + await vscode.commands.executeCommand("oil-code.select"); + await sleep(200); + + const testContent = + "# Test File\nThis is test content for copy/move operation."; + await vscode.window.activeTextEditor?.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), testContent); + }); + + await saveFile(); + + // Return to oil view + await vscode.commands.executeCommand("oil-code.open"); + await sleep(100); + + const editor2 = vscode.window.activeTextEditor; + assert.ok(editor2, "No active editor2"); + + // Edit the oil view to copy and move the file in one action + await editor2.edit((editBuilder) => { + // Remove the original file line + editBuilder.delete( + new vscode.Range(new vscode.Position(2, 0), new vscode.Position(3, 0)) + ); + }); + + // Select the target directory + const targetPosition = new vscode.Position(1, 0); + editor2.selection = new vscode.Selection(targetPosition, targetPosition); + await vscode.commands.executeCommand("oil-code.select"); + await sleep(300); + + const editor3 = vscode.window.activeTextEditor; + assert.ok(editor3, "No active editor3"); + // Insert the copied file line + await editor3.edit((editBuilder) => { + editBuilder.insert( + new vscode.Position(1, 0), + `${newline}/001 source-file-rename.md` + ); + editBuilder.insert( + new vscode.Position(2, 0), + `${newline}/001 source-file.md` + ); + }); + + await saveFile(); + + await waitForDocumentText([ + "/000 ../", + "/003 source-file-rename.md", + "/004 source-file.md", + ]); + + await assertProjectFileStructure([ + "target-dir/", + " source-file.md", + " source-file-rename.md", + ]); + + // Check content of copied file + const copiedFileUri = vscode.Uri.joinPath( + vscode.workspace.workspaceFolders![0].uri, + "target-dir", + "source-file-rename.md" + ); + const copiedFileContent = await vscode.workspace.fs.readFile(copiedFileUri); + assert.strictEqual( + copiedFileContent.toString(), + testContent, + "Content of copied file does not match expected content" + ); + // Check content of moved file + const movedFileUri = vscode.Uri.joinPath( + vscode.workspace.workspaceFolders![0].uri, + "target-dir", + "source-file.md" + ); + const movedFileContent = await vscode.workspace.fs.readFile(movedFileUri); + assert.strictEqual( + movedFileContent.toString(), + testContent, + "Content of moved file does not match expected content" + ); + }); }); From 4ad08c0caf2dcad1f4dd2a04859e1f189b708fa9 Mon Sep 17 00:00:00 2001 From: Corwin Marsh <7642548+corwinm@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:52:23 -0700 Subject: [PATCH 3/4] fix: Allow move and copy same file --- src/handlers/onDidSaveTextDocument.ts | 139 ++++++++++++++++---------- src/test/extension.test.ts | 6 +- 2 files changed, 90 insertions(+), 55 deletions(-) diff --git a/src/handlers/onDidSaveTextDocument.ts b/src/handlers/onDidSaveTextDocument.ts index 104d792..83ae6a3 100644 --- a/src/handlers/onDidSaveTextDocument.ts +++ b/src/handlers/onDidSaveTextDocument.ts @@ -93,6 +93,15 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) { allDestinations.add(path); }); + const replacedDeletedLines = new Set(); + // Check existing files that would be deleted and remove from existingFileConflicts + deletedLines.forEach((path) => { + if (existingFileConflicts.has(path)) { + existingFileConflicts.delete(path); + replacedDeletedLines.add(path); + } + }); + // Check for duplicate destinations or existing file conflicts if (duplicateDestinations.size > 0 || existingFileConflicts.size > 0) { logger.debug( @@ -166,6 +175,79 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) { return; } logger.debug("Processing changes..."); + + // Delete files/directories + for (const line of replacedDeletedLines) { + const filePath = line; + try { + if (line.endsWith(path.sep)) { + // This is a directory - remove recursively + await removeDirectoryRecursively(filePath); + } else { + // This is a file + fs.unlinkSync(filePath); + } + } catch (error) { + vscode.window.showErrorMessage( + `Failed to delete: ${line} - ${error}` + ); + } + } + + // Create new files/directories + for (const line of addedLines) { + const newFilePath = line; + if (line.endsWith(path.sep)) { + // Create directory + try { + fs.mkdirSync(newFilePath, { recursive: true }); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to create directory: ${line} - ${error}` + ); + } + } else { + // Create empty file + try { + // If it's a file in subfolders, ensure the folders exist + if (line.includes(path.sep)) { + const dirPath = path.dirname(newFilePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + } + fs.writeFileSync(newFilePath, ""); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to create file: ${line} - ${error}` + ); + } + } + } + + // Copy files + for (const [oldPath, newPath] of copiedLines) { + try { + // Create directory structure if needed + const isDir = newPath.endsWith(path.sep); + const dirPath = isDir ? newPath : path.dirname(newPath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + if (!isDir) { + // Copy the file to the new location + fs.copyFileSync(oldPath, newPath); + } + } catch (error) { + vscode.window.showErrorMessage( + `Failed to copy file: ${formatPath(oldPath)} to ${newPath.replace( + currentPath + path.sep, + "" + )} - ${error}` + ); + } + } + // Get the workspace edit setting const useWorkspaceEdit = getEnableWorkspaceEditSetting(); @@ -210,61 +292,14 @@ export async function onDidSaveTextDocument(document: vscode.TextDocument) { `Failed to apply workspace edit: ${error}` ); } - // Copy files - for (const [oldPath, newPath] of copiedLines) { - try { - // Create directory structure if needed - const isDir = newPath.endsWith(path.sep); - const dirPath = isDir ? newPath : path.dirname(newPath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - if (!isDir) { - // Copy the file to the new location - fs.copyFileSync(oldPath, newPath); - } - } catch (error) { - vscode.window.showErrorMessage( - `Failed to copy file: ${formatPath(oldPath)} to ${newPath.replace( - currentPath + path.sep, - "" - )} - ${error}` - ); - } - } - // Create new files/directories - for (const line of addedLines) { - const newFilePath = line; - if (line.endsWith(path.sep)) { - // Create directory - try { - fs.mkdirSync(newFilePath, { recursive: true }); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to create directory: ${line} - ${error}` - ); - } - } else { - // Create empty file - try { - // If it's a file in subfolders, ensure the folders exist - if (line.includes(path.sep)) { - const dirPath = path.dirname(newFilePath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - } - fs.writeFileSync(newFilePath, ""); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to create file: ${line} - ${error}` - ); - } - } - } // Delete files/directories for (const line of deletedLines) { + if (replacedDeletedLines.has(line)) { + // Skip lines that were already processed + continue; + } + // If the line is not in replacedDeletedLines, proceed with deletion const filePath = line; try { if (line.endsWith(path.sep)) { diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index a34c140..9e84bea 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -833,11 +833,11 @@ suite("oil.code", () => { await editor3.edit((editBuilder) => { editBuilder.insert( new vscode.Position(1, 0), - `${newline}/001 source-file-rename.md` + `${newline}/002 source-file-rename.md` ); editBuilder.insert( new vscode.Position(2, 0), - `${newline}/001 source-file.md` + `${newline}/002 source-file.md` ); }); @@ -851,8 +851,8 @@ suite("oil.code", () => { await assertProjectFileStructure([ "target-dir/", - " source-file.md", " source-file-rename.md", + " source-file.md", ]); // Check content of copied file From 61dbd960dfa8e8b5a0b408e122bf4b65d606c0b0 Mon Sep 17 00:00:00 2001 From: Corwin Marsh <7642548+corwinm@users.noreply.github.com> Date: Thu, 24 Jul 2025 23:57:37 -0700 Subject: [PATCH 4/4] test: Update windows newline --- src/test/extension.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 9e84bea..0894f58 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -798,8 +798,7 @@ suite("oil.code", () => { await vscode.commands.executeCommand("oil-code.select"); await sleep(200); - const testContent = - "# Test File\nThis is test content for copy/move operation."; + const testContent = `# Test File${newline}This is test content for copy/move operation.`; await vscode.window.activeTextEditor?.edit((editBuilder) => { editBuilder.insert(new vscode.Position(0, 0), testContent); });