diff --git a/src/commands/cd.ts b/src/commands/cd.ts new file mode 100644 index 0000000..d804554 --- /dev/null +++ b/src/commands/cd.ts @@ -0,0 +1,47 @@ +import * as vscode from "vscode"; +import { getOilState, getCurrentPath } from "../state/oilState"; +import { uriPathToDiskPath } from "../utils/pathUtils"; + +export async function cd() { + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil state."); + return; + } + const currentPath = getCurrentPath(); + if (!currentPath) { + vscode.window.showErrorMessage("No current path found."); + return; + } + + // Check if we have pending changes + if ( + oilState.editedPaths.size > 0 || + vscode.window.activeTextEditor?.document.isDirty + ) { + const result = await vscode.window.showWarningMessage( + "Discard changes?", + { modal: true }, + "Yes" + ); + if (result !== "Yes") { + return; + } + } + + const currentPathDisk = uriPathToDiskPath(currentPath); + // Update VS Code's workspace folders + try { + const folderUri = vscode.Uri.file(currentPathDisk); + + // Update the first workspace folder to the new location + // Open the new directory instead of updating workspace folders + await vscode.commands.executeCommand("vscode.openFolder", folderUri, { + forceReuseWindow: false, + }); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to change working directory: ${error}` + ); + } +} diff --git a/src/commands/close.ts b/src/commands/close.ts new file mode 100644 index 0000000..033172c --- /dev/null +++ b/src/commands/close.ts @@ -0,0 +1,16 @@ +import * as vscode from "vscode"; +import { getOilState } from "../state/oilState"; +import { checkAndEnableAutoSave } from "../utils/settings"; +import { hasPendingChanges } from "../utils/oilUtils"; + +export function closeOil() { + if (vscode.window.activeTextEditor?.document.languageId === "oil") { + const oilState = getOilState(); + if (oilState) { + if (!hasPendingChanges(oilState)) { + checkAndEnableAutoSave(); + } + } + vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } +} diff --git a/src/commands/disableUpdatePreview.ts b/src/commands/disableUpdatePreview.ts new file mode 100644 index 0000000..6d53651 --- /dev/null +++ b/src/commands/disableUpdatePreview.ts @@ -0,0 +1,9 @@ +let disableUpdatePreview = false; + +export function updateDisableUpdatePreview(value: boolean) { + disableUpdatePreview = value; +} + +export function isUpdatePreviewDisabled(): boolean { + return disableUpdatePreview; +} diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..1f282cc --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,90 @@ +import * as vscode from "vscode"; + +let helpPanel: vscode.WebviewPanel | null = null; + +export async function help() { + helpPanel?.dispose(); // Close any existing help panel + const helpHeaders = [ + "Command", + "Vim Key Binding", + "Default Shortcut", + "Description", + ]; + // Create a table of commands and default keymaps + const helpTable = [ + ["open", "-", "alt+-", "Open oil from the currents file parent directory"], + ["help", "", "alt+shift+h", "Show this help information"], + ["close", "", "alt+c", "Close oil explorer"], + ["select", "Enter", "alt+Enter", "Open selected file/directory"], + ["selectTab", "ctrl+t", "alt+t", "Open selected file in a new tab"], + ["selectVertical", "", "alt+s", "Open selected file in a vertical split"], + ["openParent", "-", "alt+-", "Navigate to parent directory"], + ["openCwd", "_", "alt_shift+-", "Navigate to workspace root"], + ["preview", "ctrl+p", "alt+p", "Preview file/directory at cursor"], + ["refresh", "ctrl+l", "alt+l", "Refresh current directory view"], + [ + "cd", + "`", + "alt+`", + "Change VSCode working directory to current oil directory", + ], + ]; + + // Display the message in a Markdown preview panel + const panel = vscode.window.createWebviewPanel( + "oilHelp", + "Oil Help", + vscode.ViewColumn.Active, + { + enableScripts: false, + } + ); + + panel.webview.html = ` + + + + + + +
+

Oil Help

+

Oil.code is a file explorer for VSCode that allows you to navigate and manage files and directories directly in your editor window.

+ + + + ${helpHeaders.map((header) => ``).join("")} + + ${helpTable + .map( + (row) => + `${row.map((cell) => ``).join("")}` + ) + .join("")} +
Available Commands with default keybinding/shortcut
${header}
${cell}
+ + +

If you find oil.code useful, please consider starring the repository on GitHub and reviewing it on the VS Code Marketplace.

+
+ + `; + + helpPanel = panel; +} diff --git a/src/commands/openCwd.ts b/src/commands/openCwd.ts new file mode 100644 index 0000000..6c27af0 --- /dev/null +++ b/src/commands/openCwd.ts @@ -0,0 +1,9 @@ +import * as vscode from "vscode"; +import { select } from "./select"; +import { logger } from "../logger"; + +export async function openCwd() { + logger.trace("Opening current working directory..."); + const cwd = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath; + await select({ overRideLineText: "../", overRideTargetPath: cwd }); +} diff --git a/src/commands/openOil.ts b/src/commands/openOil.ts new file mode 100644 index 0000000..8023498 --- /dev/null +++ b/src/commands/openOil.ts @@ -0,0 +1,49 @@ +import * as path from "path"; +import * as vscode from "vscode"; +import { oilFileProvider } from "../providers/providers"; +import { initOilStateWithPath, initOilState } from "../state/initState"; +import { setOilState } from "../state/oilState"; +import { checkAndDisableAutoSave } from "../utils/settings"; +import { logger } from "../logger"; +import { openParent } from "./openParent"; +import { positionCursorOnFile } from "../utils/oilUtils"; +import { resetPreviewState } from "../state/previewState"; + +export async function openOil(atPath?: string | undefined) { + logger.trace("Opening oil file..."); + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor?.document.languageId === "oil" && !atPath) { + openParent(); + return; + } + + const oilState = atPath ? initOilStateWithPath(atPath) : initOilState(); + setOilState(oilState); + resetPreviewState(); + + const activeFile = path.basename(activeEditor?.document.uri.fsPath || ""); + + const folderPath = oilState.currentPath; + + if (folderPath) { + try { + oilFileProvider.delete(oilState.tempFileUri); + // Open the in-memory document + const doc = await vscode.workspace.openTextDocument(oilState.tempFileUri); + await vscode.languages.setTextDocumentLanguage(doc, "oil"); + + const editor = await vscode.window.showTextDocument(doc, { + preview: false, + }); + + // Position cursor on the active file if it exists + positionCursorOnFile(editor, activeFile); + await checkAndDisableAutoSave(); + } catch (error) { + vscode.window.showErrorMessage(`Failed to open oil file: ${error}`); + } + } else { + vscode.window.showErrorMessage("Unable to determine the folder to open."); + } +} diff --git a/src/commands/openParent.ts b/src/commands/openParent.ts new file mode 100644 index 0000000..10ad927 --- /dev/null +++ b/src/commands/openParent.ts @@ -0,0 +1,7 @@ +import { select } from "./select"; +import { logger } from "../logger"; + +export async function openParent() { + logger.trace("Opening parent directory..."); + await select({ overRideLineText: "../" }); +} diff --git a/src/commands/preview.ts b/src/commands/preview.ts new file mode 100644 index 0000000..070ce88 --- /dev/null +++ b/src/commands/preview.ts @@ -0,0 +1,355 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { OIL_SCHEME, OIL_PREVIEW_SCHEME } from "../constants"; +import { getOilState, getCurrentPath } from "../state/oilState"; +import { getPreviewState, setPreviewState } from "../state/previewState"; +import { uriPathToDiskPath } from "../utils/pathUtils"; +import { getDirectoryListing } from "../utils/fileUtils"; +import { updateDecorations } from "../decorations"; +import { oilPreviewProvider } from "../providers/providers"; +import { logger } from "../logger"; +import { isUpdatePreviewDisabled } from "./disableUpdatePreview"; + +export async function preview(overrideEnabled: boolean = false) { + logger.trace("Previewing file or directory..."); + const activeEditor = vscode.window.activeTextEditor; + + if (!activeEditor) { + vscode.window.showErrorMessage("No active editor found."); + return; + } + + // Check if the current file is our oil temp file + if (activeEditor.document.uri.scheme !== OIL_SCHEME) { + return; + } + + const document = activeEditor.document; + const cursorPosition = activeEditor.selection.active; + const lineText = document.lineAt(cursorPosition.line).text; + const fileName = lineText.replace(/^\/\d{3} /, "").trim(); + + if (!fileName) { + vscode.window.showInformationMessage( + "No file or directory found under cursor." + ); + return; + } + + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil current directory."); + return; + } + + const currentFolderPath = getCurrentPath(); + if (!currentFolderPath) { + vscode.window.showErrorMessage("No current folder path found."); + return; + } + + let targetPath: string; + + // Handle "../" special case + if (fileName === "../") { + targetPath = path.dirname(uriPathToDiskPath(currentFolderPath)); + } else { + targetPath = path.join(uriPathToDiskPath(currentFolderPath), fileName); + } + + if (!fs.existsSync(targetPath)) { + vscode.window.showErrorMessage(`"${fileName}" does not exist.`); + return; + } + + const isDir = fs.lstatSync(targetPath).isDirectory(); + + // If this file/directory is already being previewed, close the preview (toggle behavior) + if (getPreviewState().previewedFile === targetPath) { + const newPreviewState = { + ...getPreviewState(), + previewEnabled: overrideEnabled, + }; + setPreviewState(newPreviewState); + if (!overrideEnabled) { + await closePreview(); + } + return; + } + + const newPreviewState = { + ...getPreviewState(), + previewFile: targetPath, + previewEnabled: true, + }; + setPreviewState(newPreviewState); + + // Preview differently based on whether it's a file or directory + if (isDir) { + await previewDirectory(targetPath); + } else { + await previewFile(targetPath); + } +} + +async function previewFile(targetPath: string) { + logger.trace("Previewing file:", targetPath); + try { + const fileExists = fs.existsSync(targetPath); + // Read the file content from disk + const fileContent = fileExists + ? await vscode.workspace.fs.readFile(vscode.Uri.file(targetPath)) + : Buffer.from(""); + + // Create a unique preview URI for the file using the original filename to preserve extension + const previewName = path.basename(targetPath); + const previewUri = vscode.Uri.parse( + `${OIL_PREVIEW_SCHEME}://oil-preview/${previewName}` + ); + + const previousPreviewUri = getPreviewState().previewUri; + + // Write content to the virtual file system + oilPreviewProvider.writeFile(previewUri, fileContent); + + // Open the virtual document + const fileDoc = await vscode.workspace.openTextDocument(previewUri); + + // Open to the side (right split) in preview mode + const editor = await vscode.window.showTextDocument(fileDoc, { + viewColumn: vscode.ViewColumn.Beside, // Opens in the editor group to the right + preview: true, + preserveFocus: true, // Keeps focus on the oil file + }); + + // Update preview state + const newPreviewState = { + ...getPreviewState(), + previewedFile: targetPath, + previewedEditor: editor, + isDirectory: false, + previewUri: previewUri, + }; + setPreviewState(newPreviewState); + + if (previousPreviewUri) { + oilPreviewProvider.delete(previousPreviewUri); + } + + // Start listening for cursor movements if not already listening + if (!getPreviewState().cursorListenerDisposable) { + logger.trace("Starting cursor listener for preview updates..."); + const newState = { + ...getPreviewState(), + cursorListenerDisposable: vscode.window.onDidChangeTextEditorSelection( + updatePreviewBasedOnCursorPosition + ), + }; + setPreviewState(newState); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to preview file: ${error}`); + } +} + +async function previewDirectory(directoryPath: string) { + logger.trace("Previewing directory:", directoryPath); + try { + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("No oil state found."); + return; + } + const previewName = path.basename(directoryPath); + + if (getPreviewState().previewedFile === directoryPath) { + return; // If already previewing this directory, do nothing + } + + // Get directory listing in oil format + const directoryContent = await getDirectoryListing( + directoryPath, + oilState, + true + ); + + const previewUri = vscode.Uri.parse( + `${OIL_PREVIEW_SCHEME}://oil-preview/${previewName}` + ); + + // Write content to the virtual file + oilPreviewProvider.writeFile(previewUri, Buffer.from(directoryContent)); + + // Open the virtual document + const fileDoc = await vscode.workspace.openTextDocument(previewUri); + await vscode.languages.setTextDocumentLanguage(fileDoc, "oil"); + + // Show the document to the side + const editor = await vscode.window.showTextDocument(fileDoc, { + viewColumn: vscode.ViewColumn.Beside, + preview: true, + preserveFocus: true, + }); + updateDecorations(editor); + + // Update preview state + const newPreviewState = { + ...getPreviewState(), + previewedFile: directoryPath, + previewedEditor: editor, + isDirectory: true, + previewUri: previewUri, + }; + setPreviewState(newPreviewState); + + // Start listening for cursor movements if not already listening + if (!getPreviewState().cursorListenerDisposable) { + logger.trace("Starting cursor listener for preview updates..."); + const newState = { + ...getPreviewState(), + cursorListenerDisposable: vscode.window.onDidChangeTextEditorSelection( + updatePreviewBasedOnCursorPosition + ), + }; + setPreviewState(newState); + } + } catch (error) { + logger.error("Failed to preview directory:", error); + } +} + +export async function closePreview() { + // Stop listening for cursor movements + if (getPreviewState().cursorListenerDisposable) { + getPreviewState().cursorListenerDisposable?.dispose(); + const newState = { ...getPreviewState(), cursorListenerDisposable: null }; + setPreviewState(newState); + } + + // Close the preview if it's open + const currentPreviewState = getPreviewState(); + if (currentPreviewState.previewedFile && currentPreviewState.previewUri) { + // For both file and directory previews using OilPreviewFileSystemProvider + const previewUri = currentPreviewState.previewUri; + + // Reset state + const newPreviewState = { + ...currentPreviewState, + previewedFile: null, + previewedEditor: null, + isDirectory: false, + previewUri: null, + }; + setPreviewState(newPreviewState); + + // Close any editors showing this virtual file + for (const editor of vscode.window.visibleTextEditors) { + if ( + editor.document.uri.scheme === OIL_PREVIEW_SCHEME && + editor.document.uri.toString() === previewUri.toString() + ) { + // Close the editor showing the preview + await vscode.commands.executeCommand( + "workbench.action.closeActiveEditor", + editor.document.uri + ); + } + } + + // Clean up the virtual file + try { + oilPreviewProvider.delete(previewUri); + } catch (err) { + logger.error("Failed to delete virtual preview file:", err); + } + } +} + +async function updatePreviewBasedOnCursorPosition( + event: vscode.TextEditorSelectionChangeEvent +) { + // Only respond to selection changes in the oil file + if ( + !event.textEditor || + event.textEditor.document.uri.scheme !== OIL_SCHEME || + isUpdatePreviewDisabled() + ) { + return; + } + logger.trace( + "updatePreviewBasedOnCursorPosition: Updating preview based on cursor position...", + event.textEditor.document.uri.toString() + ); + + const document = event.textEditor.document; + const cursorPosition = event.selections[0].active; + + // Check if line is valid + if (cursorPosition.line >= document.lineCount) { + return; + } + logger.trace( + "updatePreviewBasedOnCursorPosition: Cursor position:", + cursorPosition.line, + cursorPosition.character + ); + + const lineText = document.lineAt(cursorPosition.line).text; + const fileName = lineText.replace(/^\/\d{3} /, "").trim(); + + // Skip if cursor is on empty line + if (!fileName) { + return; + } + logger.trace("updatePreviewBasedOnCursorPosition: fileName:", fileName); + + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil state."); + return; + } + const currentFolderPath = getCurrentPath(); + if (!currentFolderPath) { + vscode.window.showErrorMessage("No current folder path found."); + return; + } + const previousPreviewedUri = getPreviewState().previewUri; + + let targetPath: string; + + // Handle "../" special case + if (fileName === "../") { + targetPath = path.dirname(uriPathToDiskPath(currentFolderPath)); + } else { + targetPath = path.join(uriPathToDiskPath(currentFolderPath), fileName); + } + + // Skip if same file/directory is already being previewed + if (getPreviewState().previewedFile === targetPath) { + return; + } + + // Determine if it's a directory or file + let isDir = false; + try { + isDir = fs.lstatSync(targetPath).isDirectory(); + } catch (error) { + isDir = false; // If it doesn't exist, treat as file + } + + // Update the preview with the new file or directory + try { + if (isDir) { + await previewDirectory(targetPath); + } else { + await previewFile(targetPath); + } + } catch (error) { + logger.error("Failed to update preview:", error); + } + + if (previousPreviewedUri) { + oilPreviewProvider.delete(previousPreviewedUri); + } +} diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts new file mode 100644 index 0000000..b66ebd4 --- /dev/null +++ b/src/commands/refresh.ts @@ -0,0 +1,86 @@ +import * as vscode from "vscode"; +import { OIL_SCHEME } from "../constants"; +import { getOilState, getCurrentPath } from "../state/oilState"; +import { uriPathToDiskPath } from "../utils/pathUtils"; +import { getDirectoryListing } from "../utils/fileUtils"; +import { updateDecorations } from "../decorations"; +import { logger } from "../logger"; + +export async function refresh() { + logger.trace("Refreshing oil file..."); + const activeEditor = vscode.window.activeTextEditor; + + // Check if we're in an oil editor + if (!activeEditor || activeEditor.document.uri.scheme !== OIL_SCHEME) { + vscode.window.showErrorMessage("Not in an oil editor."); + return; + } + + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil state."); + return; + } + + const currentPath = getCurrentPath(); + if (!currentPath) { + vscode.window.showErrorMessage("No current path found."); + return; + } + + // Check if there are any pending changes in the current path + const hasChangesInCurrentPath = oilState.editedPaths.has(currentPath); + + if (hasChangesInCurrentPath || activeEditor.document.isDirty) { + // Ask for confirmation before discarding changes + const response = await vscode.window.showWarningMessage( + "Discard changes?", + { modal: true }, + "Yes", + "No" + ); + + if (response !== "Yes") { + // User chose not to discard changes + return; + } + + // Remove the current path from edited paths + oilState.editedPaths.delete(currentPath); + } + + try { + // Clear the visited path cache for the current directory to force refresh from disk + oilState.visitedPaths.delete(currentPath); + + // Get updated directory content from disk + const directoryContent = await getDirectoryListing( + uriPathToDiskPath(currentPath), + oilState + ); + + // Create a workspace edit to update the document + const edit = new vscode.WorkspaceEdit(); + edit.replace( + activeEditor.document.uri, + new vscode.Range( + new vscode.Position(0, 0), + activeEditor.document.positionAt(activeEditor.document.getText().length) + ), + directoryContent + ); + + // Apply the edit + await vscode.workspace.applyEdit(edit); + + // Check if other directories have changes + if (oilState.editedPaths.size === 0) { + // Reset the document's dirty state + await activeEditor.document.save(); + } + + updateDecorations(activeEditor); + } catch (error) { + vscode.window.showErrorMessage(`Failed to refresh directory: ${error}`); + } +} diff --git a/src/commands/select.ts b/src/commands/select.ts new file mode 100644 index 0000000..ea43211 --- /dev/null +++ b/src/commands/select.ts @@ -0,0 +1,237 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { OIL_SCHEME } from "../constants"; +import { getOilState, getCurrentPath } from "../state/oilState"; +import { + uriPathToDiskPath, + removeTrailingSlash, + updateOilUri, +} from "../utils/pathUtils"; +import { checkForVisitedCleanup, hasPendingChanges } from "../utils/oilUtils"; +import { newline } from "../newline"; +import { updateDisableUpdatePreview } from "./disableUpdatePreview"; +import { logger } from "../logger"; +import { onDidSaveTextDocument } from "../handlers/onDidSaveTextDocument"; +import { getPreviewState } from "../state/previewState"; +import { preview } from "./preview"; + +const cursorInitChar = 5; // 5 characters for "/000 " + +export async function select({ + overRideLineText, + overRideTargetPath, + viewColumn, +}: { + overRideLineText?: string; + overRideTargetPath?: string; + viewColumn?: vscode.ViewColumn; +} = {}) { + logger.trace("Selecting file..."); + const activeEditor = vscode.window.activeTextEditor; + + if (!activeEditor) { + vscode.window.showErrorMessage("No active editor found."); + return; + } + // Check if the current file is our oil file by checking the scheme + if (activeEditor.document.uri.scheme !== OIL_SCHEME) { + return; + } + + // Capture current content before navigating + const currentContent = activeEditor.document.getText(); + const currentLines = currentContent.split(newline); + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil state."); + return; + } + const currentPath = getCurrentPath(); + if (!currentPath) { + vscode.window.showErrorMessage("No current path found."); + return; + } + const currentFileDiskPath = uriPathToDiskPath(currentPath); + const currentFile = path.basename(currentFileDiskPath); + + // If the document has unsaved changes, capture them before navigating + const isDirty = activeEditor.document.isDirty; + if (isDirty) { + oilState.editedPaths.set(currentPath, currentLines); + } + + const document = activeEditor.document; + const cursorPosition = activeEditor.selection.active; + const rawLineText = + overRideLineText ?? document.lineAt(cursorPosition.line).text; + + // Extract actual filename by removing the hidden identifier + const lineText = rawLineText.replace(/^\/\d{3} /, ""); + const fileName = lineText.trim(); + + if (!fileName) { + vscode.window.showErrorMessage( + "No file name or directory found under the cursor." + ); + return; + } + + if (!currentFileDiskPath) { + vscode.window.showErrorMessage("No current folder path found."); + return; + } + const targetPath = removeTrailingSlash( + overRideTargetPath + ? overRideTargetPath + : path.join(currentFileDiskPath, fileName) + ); + + // Store the current directory name when going up a directory + let isGoingUp = fileName === "../"; + + updateDisableUpdatePreview(true); + + if (fs.existsSync(targetPath) && fs.lstatSync(targetPath).isDirectory()) { + try { + // Update the URI to represent the new directory path + const oldUri = document.uri; + + // Update the URI to reflect the new directory + const newUri = updateOilUri(oilState, targetPath); + + // Open the document with the new URI + const newDoc = await vscode.workspace.openTextDocument(newUri); + await vscode.languages.setTextDocumentLanguage(newDoc, "oil"); + + // Show the new document in the same editor + const editor = await vscode.window.showTextDocument(newDoc, { + viewColumn: viewColumn || activeEditor.viewColumn, + preview: false, + }); + + if (!viewColumn) { + // Close the old document + await vscode.window.showTextDocument(oldUri); + await vscode.commands.executeCommand( + "workbench.action.revertAndCloseActiveEditor" + ); + } + + // Position cursor appropriately + if (isGoingUp) { + // When going up a directory, we need to find the directory we came from + const lastSelected = currentFile.replace(/^\/\d{3} /, ""); + + // Use setTimeout to ensure the editor content is updated + setTimeout(() => { + const editorForSelection = vscode.window.activeTextEditor; + if (editorForSelection) { + // Find the line with the directory name (with trailing slash) + const docText = editorForSelection.document.getText(); + const lines = docText.split(newline); + + let foundIndex = -1; + // Look for the folder we came from with or without trailing slash + for (let i = 0; i < lines.length; i++) { + // Extract actual name from each line by removing the hidden identifier + const lineName = lines[i].replace(/^\/\d{3} /, "").trim(); + if ( + lineName === lastSelected || + lineName === `${lastSelected}/` + ) { + foundIndex = i; + break; + } + } + + if (foundIndex >= 0) { + // Position cursor at the found line + editorForSelection.selection = new vscode.Selection( + foundIndex, + cursorInitChar, + foundIndex, + cursorInitChar + ); + editorForSelection.revealRange( + new vscode.Range(foundIndex, 0, foundIndex, 0) + ); + } else { + // Default to first line if not found + editorForSelection.selection = new vscode.Selection(0, 0, 0, 0); + } + updateDisableUpdatePreview(false); + if (getPreviewState().previewEnabled) { + preview(true); + } + } + }, 100); + } else { + setTimeout(() => { + // When going into a directory, position at first line + editor.selection = new vscode.Selection(0, 0, 0, 0); + updateDisableUpdatePreview(false); + // Manually update preview if enabled + if (getPreviewState().previewEnabled) { + preview(true); + } + }, 100); + } + + // Mark the file as modified if there are pending changes + if (!hasPendingChanges(oilState)) { + editor.document.save(); + } + + return; + } catch (error) { + vscode.window.showErrorMessage( + `Failed to navigate to the directory: ${error}` + ); + return; + } + } else if (!fs.existsSync(targetPath)) { + updateDisableUpdatePreview(false); + // If the file doesn't exist, ask if the user wants to save changes + const saveChanges = await vscode.window.showWarningMessage( + `Save Changes?`, + { modal: true }, + "Yes", + "No" + ); + + if (saveChanges === "Yes") { + oilState.openAfterSave = fileName; + if (document.isDirty && !hasPendingChanges(oilState)) { + await document.save(); + } else { + await onDidSaveTextDocument(document); + } + return; + } + vscode.window.showErrorMessage(`File "${fileName}" does not exist.`); + return; + } + + try { + // If their are no open oil files and no edits, reset state + checkForVisitedCleanup(oilState); + + const fileUri = vscode.Uri.file(targetPath); + const fileDoc = await vscode.workspace.openTextDocument(fileUri); + const viewColumnToUse = viewColumn || activeEditor.viewColumn; + if (!viewColumn) { + await vscode.window.showTextDocument(activeEditor.document.uri); + await vscode.commands.executeCommand( + "workbench.action.revertAndCloseActiveEditor" + ); + } + await vscode.window.showTextDocument(fileDoc, { + viewColumn: viewColumnToUse, + preview: false, + }); + updateDisableUpdatePreview(false); + } catch (error) { + vscode.window.showErrorMessage(`Failed to open file.`); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..3001984 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode"; + +export interface OilEntry { + identifier: string; + value: string; + path: string; + isDir: boolean; +} + +export interface OilState { + tempFileUri: vscode.Uri; + currentPath: string; + identifierCounter: number; + visitedPaths: Map; + editedPaths: Map; + openAfterSave?: string; +} + +export interface PreviewState { + previewedFile: string | null; + previewedEditor: vscode.TextEditor | null; + cursorListenerDisposable: vscode.Disposable | null; + isDirectory: boolean; + previewUri: vscode.Uri | null; + previewEnabled: boolean; +} + +// Custom URI scheme for main oil files +export const OIL_SCHEME = "oil"; + +// Custom URI scheme for oil preview files +export const OIL_PREVIEW_SCHEME = "oil-preview"; + +export const MAX_EXTENSION_DETECTION_RETRIES = 6; +export const EXTENSION_DETECTION_DELAY = 500; // ms + +export const GO_UP_IDENTIFIER = "/000"; diff --git a/src/extension.ts b/src/extension.ts index 4a3168b..8869552 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,1966 +1,30 @@ -import path from "path"; import * as vscode from "vscode"; -import * as fs from "fs"; -import { activateDecorations, updateDecorations } from "./decorations"; -import { newline } from "./newline"; -import oilCodeLua from "./oil.code.lua"; - -const logger = vscode.window.createOutputChannel("oil.code", { log: true }); - -interface OilEntry { - identifier: string; - value: string; - path: string; - isDir: boolean; -} - -interface OilState { - tempFileUri: vscode.Uri; - currentPath: string | undefined; - identifierCounter: number; - visitedPaths: Map; - editedPaths: Map; - openAfterSave?: string; -} - -let oilState: OilState | undefined; - -// Custom URI scheme for main oil files -const OIL_SCHEME = "oil"; - -// Custom URI scheme for oil preview files -const OIL_PREVIEW_SCHEME = "oil-preview"; - -function initOilState() { - const currentOrWorkspacePath = normalizePathToUri( - vscode.window.activeTextEditor - ? path.dirname(vscode.window.activeTextEditor.document.uri.fsPath) - : vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath - ); - - const tempFileUri = vscode.Uri.parse( - `${OIL_SCHEME}://oil${currentOrWorkspacePath}` - ); - - const existingState = oilState; - if (existingState) { - existingState.tempFileUri = tempFileUri; - existingState.currentPath = currentOrWorkspacePath; - // If the state already exists, return it - return existingState; - } - - const newState = { - tempFileUri: tempFileUri, - currentPath: currentOrWorkspacePath, - identifierCounter: 1, - visitedPaths: new Map(), - editedPaths: new Map(), - }; - - oilState = newState; - - return newState; -} - -function initOilStateWithPath(path: string) { - const normalizedPath = normalizePathToUri(path); - const tempFileUri = vscode.Uri.parse(`${OIL_SCHEME}://oil${normalizedPath}`); - - const newState = { - tempFileUri: tempFileUri, - currentPath: normalizedPath, - identifierCounter: 1, - visitedPaths: new Map(), - editedPaths: new Map(), - }; - - oilState = newState; - - return newState; -} - -function getOilState(): OilState | undefined { - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor && oilState) { - const documentUri = activeEditor.document.uri; - if (documentUri.scheme === OIL_SCHEME) { - return oilState; - } - } - return undefined; -} - -function getCurrentPath(): string | undefined { - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor && oilState) { - const documentUri = activeEditor.document.uri; - if (documentUri.scheme === OIL_SCHEME) { - return documentUri.path; - } - } - return undefined; -} - -function normalizePathToUri(path: string | undefined = ""): string { - if (path.startsWith("/")) { - return path; - } - // Normalize the path to a URI format - const normalizedPath = path.replace(/\\/g, "/"); - return `/${normalizedPath}`; -} - -function uriPathToDiskPath(path: string): string { - // If Windows, convert URI path to disk path - if (process.platform === "win32") { - return path.replace(/%20/g, " ").replace(/^\//, "").replaceAll("/", "\\"); - } - // For other platforms, return the path as is - return path; -} - -function removeTrailingSlash(path: string): string { - return path.endsWith("/") ? path.slice(0, -1) : path; -} - -// Helper function to update the URI when changing directories -function updateOilUri(oilState: OilState, newPath: string): vscode.Uri { - const normalizedPath = removeTrailingSlash(normalizePathToUri(newPath)); - const newUri = vscode.Uri.parse(`${OIL_SCHEME}://oil${normalizedPath}`); - - // Update the state with the new URI - oilState.tempFileUri = newUri; - - return newUri; -} - -// Virtual file system provider for oil main files -class OilFileSystemProvider implements vscode.FileSystemProvider { - private _onDidChangeFile = new vscode.EventEmitter< - vscode.FileChangeEvent[] - >(); - readonly onDidChangeFile = this._onDidChangeFile.event; - - private documents = new Map(); - - // Create or update an in-memory document - writeFile(uri: vscode.Uri, content: Uint8Array): void { - this.documents.set(uri.toString(), content); - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); - } - - // Read an in-memory document - readFile(uri: vscode.Uri): Uint8Array { - const content = this.documents.get(uri.toString()); - if (content) { - return content; - } - throw vscode.FileSystemError.FileNotFound(uri); - } - - // Delete an in-memory document - delete(uri: vscode.Uri): void { - this.documents.delete(uri.toString()); - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); - } - - // Required methods for FileSystemProvider interface - watch(_uri: vscode.Uri): vscode.Disposable { - return { dispose: () => {} }; - } - stat(_uri: vscode.Uri): vscode.FileStat { - return { - type: vscode.FileType.File, - ctime: Date.now(), - mtime: Date.now(), - size: this.documents.get(_uri.toString())?.length || 0, - }; - } - readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { - return []; - } - createDirectory(_uri: vscode.Uri): void {} - rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void {} -} - -// Create the provider -const oilFileProvider = new OilFileSystemProvider(); - -// Virtual file system provider for oil preview documents -class OilPreviewFileSystemProvider implements vscode.FileSystemProvider { - private _onDidChangeFile = new vscode.EventEmitter< - vscode.FileChangeEvent[] - >(); - readonly onDidChangeFile = this._onDidChangeFile.event; - - private documents = new Map(); - - // Create or update an in-memory document - writeFile(uri: vscode.Uri, content: Uint8Array): void { - this.documents.set(uri.toString(), content); - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); - } - - // Read an in-memory document - readFile(uri: vscode.Uri): Uint8Array { - const content = this.documents.get(uri.toString()); - if (content) { - return content; - } - return new Uint8Array(); // Return empty array if not found - } - - // Delete an in-memory document - delete(uri: vscode.Uri): void { - this.documents.delete(uri.toString()); - this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Deleted, uri }]); - } - - // Required methods for FileSystemProvider interface - watch(_uri: vscode.Uri): vscode.Disposable { - return { dispose: () => {} }; - } - stat(_uri: vscode.Uri): vscode.FileStat { - return { - type: vscode.FileType.File, - ctime: Date.now(), - mtime: Date.now(), - size: this.documents.get(_uri.toString())?.length || 0, - }; - } - readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { - return []; - } - createDirectory(_uri: vscode.Uri): void {} - rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void {} -} - -// Create and register the provider -const oilPreviewProvider = new OilPreviewFileSystemProvider(); - -let restoreAutoSave = false; -async function checkAndDisableAutoSave() { - const config = vscode.workspace.getConfiguration("files"); - const autoSave = config.get("autoSave"); - if (autoSave === "afterDelay") { - restoreAutoSave = true; - await config.update("autoSave", "off", vscode.ConfigurationTarget.Global); - } -} - -async function checkAndEnableAutoSave() { - const config = vscode.workspace.getConfiguration("files"); - if (restoreAutoSave) { - await config.update( - "autoSave", - "afterDelay", - vscode.ConfigurationTarget.Global - ); - } -} - -async function openOil(atPath?: string | undefined) { - logger.trace("Opening oil file..."); - const activeEditor = vscode.window.activeTextEditor; - - if (activeEditor?.document.languageId === "oil" && !atPath) { - openParent(); - return; - } - - const oilState = atPath ? initOilStateWithPath(atPath) : initOilState(); - const activeFile = path.basename(activeEditor?.document.uri.fsPath || ""); - - const folderPath = oilState.currentPath; - - if (folderPath) { - try { - // Get the directory listing - let directoryContent = await getDirectoryListing(folderPath, oilState); - - // Create an in-memory file - oilFileProvider.writeFile( - oilState.tempFileUri, - Buffer.from(directoryContent) - ); - - // Open the in-memory document - const doc = await vscode.workspace.openTextDocument(oilState.tempFileUri); - await vscode.languages.setTextDocumentLanguage(doc, "oil"); - - const editor = await vscode.window.showTextDocument(doc, { - preview: false, - }); - - // Position cursor on the previously selected file if it exists in this directory - positionCursorOnFile(editor, activeFile); - - await checkAndDisableAutoSave(); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to create or open the oil file: ${error}` - ); - } - } else { - vscode.window.showErrorMessage("Unable to determine the folder to open."); - } -} - -function closeOil() { - if (vscode.window.activeTextEditor?.document.languageId === "oil") { - const oilState = getOilState(); - if (oilState) { - checkForVisitedCleanup(oilState); - } - vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - } -} - -async function getDirectoryListing( - folderPath: string, - oilState: OilState, - preview: boolean = false -): Promise { - let pathUri = vscode.Uri.file(folderPath); - - const folderPathUri = removeTrailingSlash(normalizePathToUri(folderPath)); - if (oilState.editedPaths.has(folderPathUri)) { - return oilState.editedPaths.get(folderPathUri)!.join(newline); - } - - if ( - oilState.editedPaths.size > 0 && - oilState.visitedPaths.has(folderPathUri) - ) { - // If we have visited this path before, return the cached listing - return oilState.visitedPaths.get(folderPathUri)!.join(newline); - } - - let results = await vscode.workspace.fs.readDirectory(pathUri); - - results.sort(([aName, aType], [bName, bType]) => { - return aType & vscode.FileType.Directory - ? bType & vscode.FileType.Directory - ? 0 - : -1 - : aName < bName - ? -1 - : 1; - }); - - let listings = results.map(([name, type]) => { - return type & vscode.FileType.Directory ? `${name}/` : name; - }); - - let hasParent = path.dirname(folderPath) !== folderPath; - if (hasParent) { - listings.unshift("../"); - } - - let existingFiles = new Map(); - // Diff against the cached version and remove any deleted files and add new ones - if (oilState.visitedPaths.has(folderPathUri)) { - const previousListings = oilState.visitedPaths.get(folderPathUri)!; - for (const file of previousListings) { - const fileName = file.slice(4); - if (listings.includes(fileName)) { - existingFiles.set(fileName, file); - } - } - } - - // Generate listings with hidden identifiers using global counter - let listingsWithIds = listings.map((name) => { - if (name === "../") { - return `/000 ${name}`; - } - if (existingFiles.has(name)) { - return existingFiles.get(name)!; - } - - // Use and increment the global counter for each file/directory - const identifier = preview - ? "/000" - : `/${oilState.identifierCounter.toString().padStart(3, "0")}`; - - if (!preview) { - oilState.identifierCounter++; - } - - return `${identifier} ${name}`; - }); - - oilState.visitedPaths.set(folderPathUri, listingsWithIds); - - return listingsWithIds.join(newline); -} - -async function select({ - overRideLineText, - overRideTargetPath, - viewColumn, -}: { - overRideLineText?: string; - overRideTargetPath?: string; - viewColumn?: vscode.ViewColumn; -} = {}) { - logger.trace("Selecting file..."); - const activeEditor = vscode.window.activeTextEditor; - - if (!activeEditor) { - vscode.window.showErrorMessage("No active editor found."); - return; - } - // Check if the current file is our oil file by checking the scheme - if (activeEditor.document.uri.scheme !== OIL_SCHEME) { - return; - } - - // Capture current content before navigating - const currentContent = activeEditor.document.getText(); - const currentLines = currentContent.split(newline); - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil state."); - return; - } - const currentPath = getCurrentPath(); - if (!currentPath) { - vscode.window.showErrorMessage("No current path found."); - return; - } - const currentFileDiskPath = uriPathToDiskPath(currentPath); - const currentFile = path.basename(currentFileDiskPath); - - // If the document has unsaved changes, capture them before navigating - const isDirty = activeEditor.document.isDirty; - if (isDirty) { - oilState.editedPaths.set(currentPath, currentLines); - } - - const document = activeEditor.document; - const cursorPosition = activeEditor.selection.active; - const rawLineText = - overRideLineText ?? document.lineAt(cursorPosition.line).text; - - // Extract actual filename by removing the hidden identifier - const lineText = rawLineText.replace(/^\/\d{3} /, ""); - const fileName = lineText.trim(); - - if (!fileName) { - vscode.window.showErrorMessage( - "No file name or directory found under the cursor." - ); - return; - } - - if (!currentFileDiskPath) { - vscode.window.showErrorMessage("No current folder path found."); - return; - } - const targetPath = removeTrailingSlash( - overRideTargetPath - ? overRideTargetPath - : path.join(currentFileDiskPath, fileName) - ); - - // Store the current directory name when going up a directory - let isGoingUp = fileName === "../"; - - disableUpdatePreview = true; - - if (fs.existsSync(targetPath) && fs.lstatSync(targetPath).isDirectory()) { - try { - // Update the URI to represent the new directory path - const oldUri = document.uri; - - // Update the URI to reflect the new directory - const newUri = updateOilUri(oilState, targetPath); - - const directoryContent = await getDirectoryListing(targetPath, oilState); - - // Transfer the content to the new URI - oilFileProvider.writeFile(newUri, Buffer.from(directoryContent)); - - // Open the document with the new URI - const newDoc = await vscode.workspace.openTextDocument(newUri); - await vscode.languages.setTextDocumentLanguage(newDoc, "oil"); - - // Show the new document in the same editor - const editor = await vscode.window.showTextDocument(newDoc, { - viewColumn: viewColumn || activeEditor.viewColumn, - preview: false, - }); - - if (!viewColumn) { - // Close the old document - await vscode.window.showTextDocument(oldUri); - await vscode.commands.executeCommand( - "workbench.action.revertAndCloseActiveEditor" - ); - } - - // Position cursor appropriately - if (isGoingUp) { - // When going up a directory, we need to find the directory we came from - const lastSelected = currentFile.replace(/^\/\d{3} /, ""); - - // Use setTimeout to ensure the editor content is updated - setTimeout(() => { - if (editor) { - // Find the line with the directory name (with trailing slash) - const docText = editor.document.getText(); - const lines = docText.split(newline); - - let foundIndex = -1; - // Look for the folder we came from with or without trailing slash - for (let i = 0; i < lines.length; i++) { - // Extract actual name from each line by removing the hidden identifier - const lineName = lines[i].replace(/^\/\d{3} /, "").trim(); - if ( - lineName === lastSelected || - lineName === `${lastSelected}/` - ) { - foundIndex = i; - break; - } - } - - disableUpdatePreview = false; - if (foundIndex >= 0) { - // Position cursor at the found line - editor.selection = new vscode.Selection( - foundIndex, - 0, - foundIndex, - 0 - ); - editor.revealRange( - new vscode.Range(foundIndex, 0, foundIndex, 0) - ); - } else { - // Default to first line if not found - editor.selection = new vscode.Selection(0, 0, 0, 0); - } - if (previewState.previewEnabled) { - preview(true); - } - } - }, 100); - } else { - setTimeout(() => { - disableUpdatePreview = false; - // When going into a directory, position at first line - editor.selection = new vscode.Selection(0, 0, 0, 0); - // Manually update preview if enabled - if (previewState.previewEnabled) { - preview(true); - } - }, 100); - } - - // Mark the file as modified if there are pending changes - if (!hasPendingChanges()) { - editor.document.save(); - } - - return; - } catch (error) { - vscode.window.showErrorMessage( - `Failed to navigate to the directory: ${error}` - ); - return; - } - } else if (!fs.existsSync(targetPath)) { - // If the file doesn't exist, ask if the user wants to save changes - const saveChanges = await vscode.window.showWarningMessage( - `Save Changes?`, - { modal: true }, - "Yes", - "No" - ); - - if (saveChanges === "Yes") { - oilState.openAfterSave = fileName; - if (document.isDirty && !hasPendingChanges()) { - await document.save(); - } else { - await onDidSaveTextDocument(document); - } - return; - } - vscode.window.showErrorMessage(`File "${fileName}" does not exist.`); - return; - } - - try { - // If their are no open oil files and no edits, reset state - checkForVisitedCleanup(oilState); - - const fileUri = vscode.Uri.file(targetPath); - const fileDoc = await vscode.workspace.openTextDocument(fileUri); - const viewColumnToUse = viewColumn || activeEditor.viewColumn; - if (!viewColumn) { - await vscode.window.showTextDocument(activeEditor.document.uri); - await vscode.commands.executeCommand( - "workbench.action.revertAndCloseActiveEditor" - ); - } - await vscode.window.showTextDocument(fileDoc, { - viewColumn: viewColumnToUse, - preview: false, - }); - } catch (error) { - vscode.window.showErrorMessage(`Failed to open file.`); - } -} - -function checkForVisitedCleanup(oilState: OilState) { - if ( - vscode.window.visibleTextEditors.filter( - (editor) => editor.document.languageId === "oil" - ).length === 1 && - !oilState.editedPaths.size - ) { - oilState.visitedPaths.clear(); - oilState.identifierCounter = 1; - } -} - -async function openParent() { - logger.trace("Opening parent directory..."); - await select({ overRideLineText: "../" }); -} - -async function openCwd() { - logger.trace("Opening current working directory..."); - const cwd = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath; - await select({ overRideLineText: "../", overRideTargetPath: cwd }); -} - -async function onDidChangeActiveTextEditor( - editor: vscode.TextEditor | undefined -) { - if (!editor) { - return; - } - const oilState = getOilState(); - - // Close preview when leaving oil view - if ( - ![OIL_SCHEME, OIL_PREVIEW_SCHEME].includes(editor.document.uri.scheme) && - previewState.previewedFile - ) { - await closePreview(); - } - - // If we are returning to an oil file and preview is enabled, update the preview - if ( - editor.document.uri.scheme === OIL_SCHEME && - previewState.previewEnabled - ) { - await preview(true); - } - - if (!oilState) { - await checkAndEnableAutoSave(); - return; - } -} - -// Function to position cursor on a specific file or on the first line -function positionCursorOnFile(editor: vscode.TextEditor, fileName: string) { - if (!editor) { - return; - } - - const document = editor.document; - const text = document.getText(); - const lines = text.split(newline); - - // If no filename is provided or it's going up a directory, place cursor on first line - if (!fileName || fileName === "../") { - editor.selection = new vscode.Selection(0, 0, 0, 0); - editor.revealRange(new vscode.Range(0, 0, 0, 0)); - return; - } - - // Find the line number of the file - for (let i = 0; i < lines.length; i++) { - // Extract name without hidden identifier - const lineName = lines[i].replace(/^\/\d{3} /, "").trim(); - if (lineName === fileName || lineName === `${fileName}/`) { - // Position cursor at the beginning of the line - editor.selection = new vscode.Selection(i, 0, i, 0); - editor.revealRange(new vscode.Range(i, 0, i, 0)); - return; - } - } - - // If file not found, position on first line - editor.selection = new vscode.Selection(0, 0, 0, 0); - editor.revealRange(new vscode.Range(0, 0, 0, 0)); -} - -// Helper function to check if there are pending changes -function hasPendingChanges(): boolean { - const oilState = getOilState(); - if (!oilState) { - return false; - } - return oilState.editedPaths.size > 0; -} - -// Function to refresh the current oil view -async function refresh() { - logger.trace("Refreshing oil file..."); - const activeEditor = vscode.window.activeTextEditor; - - // Check if we're in an oil editor - if (!activeEditor || activeEditor.document.uri.scheme !== OIL_SCHEME) { - vscode.window.showErrorMessage("Not in an oil editor."); - return; - } - - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil state."); - return; - } - - const currentPath = getCurrentPath(); - if (!currentPath) { - vscode.window.showErrorMessage("No current path found."); - return; - } - - // Check if there are any pending changes in the current path - const hasChangesInCurrentPath = oilState.editedPaths.has(currentPath); - - if (hasChangesInCurrentPath || activeEditor.document.isDirty) { - // Ask for confirmation before discarding changes - const response = await vscode.window.showWarningMessage( - "Discard changes?", - { modal: true }, - "Yes", - "No" - ); - - if (response !== "Yes") { - // User chose not to discard changes - return; - } - - // Remove the current path from edited paths - oilState.editedPaths.delete(currentPath); - } - - try { - // Clear the visited path cache for the current directory to force refresh from disk - oilState.visitedPaths.delete(currentPath); - - // Get updated directory content from disk - const directoryContent = await getDirectoryListing(currentPath, oilState); - - // Create a workspace edit to update the document - const edit = new vscode.WorkspaceEdit(); - edit.replace( - activeEditor.document.uri, - new vscode.Range( - new vscode.Position(0, 0), - activeEditor.document.positionAt(activeEditor.document.getText().length) - ), - directoryContent - ); - - // Apply the edit - await vscode.workspace.applyEdit(edit); - - // Check if other directories have changes - if (!hasPendingChanges()) { - // Reset the document's dirty state - await activeEditor.document.save(); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to refresh directory: ${error}`); - } -} - -// Helper function to recursively remove a directory and its contents -async function removeDirectoryRecursively(dirPath: string): Promise { - if (!fs.existsSync(dirPath)) { - return; - } - - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - if (entry.isDirectory()) { - // Recursively remove subdirectories - await removeDirectoryRecursively(fullPath); - } else { - // Remove files - fs.unlinkSync(fullPath); - } - } - - // Remove the now-empty directory - fs.rmdirSync(dirPath); -} - -// Add tracking for previewed file -interface PreviewState { - previewedFile: string | null; // Path of currently previewed file/directory - previewedEditor: vscode.TextEditor | null; // Reference to preview editor - cursorListenerDisposable: vscode.Disposable | null; // For tracking cursor movements - isDirectory: boolean; // Whether the preview is showing a directory - previewUri: vscode.Uri | null; // URI to the virtual preview document (used for both files and directories) - previewEnabled: boolean; // Whether preview is enabled -} - -let previewState: PreviewState = { - previewedFile: null, - previewedEditor: null, - cursorListenerDisposable: null, - isDirectory: false, - previewUri: null, - previewEnabled: false, // Whether preview is enabled -}; - -// Function to preview a file -async function previewFile(targetPath: string) { - try { - const fileExists = fs.existsSync(targetPath); - // Read the file content from disk - const fileContent = fileExists - ? await vscode.workspace.fs.readFile(vscode.Uri.file(targetPath)) - : Buffer.from(""); - - // Create a unique preview URI for the file using the original filename to preserve extension - const previewName = path.basename(targetPath); - const previewUri = vscode.Uri.parse( - `${OIL_PREVIEW_SCHEME}://oil-preview/${previewName}` - ); - - const previousPreviewUri = previewState.previewUri; - // Write content to the virtual file system - oilPreviewProvider.writeFile(previewUri, fileContent); - - // Open the virtual document - const fileDoc = await vscode.workspace.openTextDocument(previewUri); - - // Open to the side (right split) in preview mode - const editor = await vscode.window.showTextDocument(fileDoc, { - viewColumn: vscode.ViewColumn.Beside, // Opens in the editor group to the right - preview: true, - preserveFocus: true, // Keeps focus on the oil file - }); - - // Update preview state - previewState.previewedFile = targetPath; - previewState.previewedEditor = editor; - previewState.isDirectory = false; - previewState.previewUri = previewUri; - - if (previousPreviewUri) { - oilPreviewProvider.delete(previousPreviewUri); - } - - // Start listening for cursor movements if not already listening - if (!previewState.cursorListenerDisposable) { - previewState.cursorListenerDisposable = - vscode.window.onDidChangeTextEditorSelection( - updatePreviewBasedOnCursorPosition - ); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to preview file: ${error}`); - } -} - -// Function to preview a directory with in-memory document -async function previewDirectory(directoryPath: string) { - try { - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("No oil state found."); - return; - } - const previewName = path.basename(directoryPath); - - if (previewState.previewedFile === directoryPath) { - return; // If already previewing this directory, do nothing - } - - // Get directory listing in oil format - const directoryContent = await getDirectoryListing( - directoryPath, - oilState, - true - ); - - const previewUri = vscode.Uri.parse( - `${OIL_PREVIEW_SCHEME}://oil-preview/${previewName}` - ); - - // Write content to the virtual file - oilPreviewProvider.writeFile(previewUri, Buffer.from(directoryContent)); - - // Open the virtual document - const fileDoc = await vscode.workspace.openTextDocument(previewUri); - await vscode.languages.setTextDocumentLanguage(fileDoc, "oil"); - - // Show the document to the side - const editor = await vscode.window.showTextDocument(fileDoc, { - viewColumn: vscode.ViewColumn.Beside, - preview: true, - preserveFocus: true, - }); - updateDecorations(editor); - - // Update preview state - previewState.previewedFile = directoryPath; - previewState.previewedEditor = editor; - previewState.isDirectory = true; - previewState.previewUri = previewUri; - - // Start listening for cursor movements if not already listening - if (!previewState.cursorListenerDisposable) { - previewState.cursorListenerDisposable = - vscode.window.onDidChangeTextEditorSelection( - updatePreviewBasedOnCursorPosition - ); - } - } catch (error) { - logger.error("Failed to preview directory:", error); - } -} - -let disableUpdatePreview = false; - -// Helper function to update preview based on cursor position -async function updatePreviewBasedOnCursorPosition( - event: vscode.TextEditorSelectionChangeEvent -) { - // Only respond to selection changes in the oil file - if ( - !event.textEditor || - event.textEditor.document.uri.scheme !== OIL_SCHEME || - disableUpdatePreview - ) { - return; - } - - const document = event.textEditor.document; - const cursorPosition = event.selections[0].active; - - // Check if line is valid - if (cursorPosition.line >= document.lineCount) { - return; - } - - const lineText = document.lineAt(cursorPosition.line).text; - const fileName = lineText.replace(/^\/\d{3} /, "").trim(); - - // Skip if cursor is on empty line - if (!fileName) { - return; - } - - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil state."); - return; - } - const currentFolderPath = getCurrentPath(); - if (!currentFolderPath) { - vscode.window.showErrorMessage("No current folder path found."); - return; - } - const previousPreviewedUri = previewState.previewUri; - - let targetPath: string; - - // Handle "../" special case - if (fileName === "../") { - targetPath = path.dirname(uriPathToDiskPath(currentFolderPath)); - } else { - targetPath = path.join(uriPathToDiskPath(currentFolderPath), fileName); - } - - // Skip if same file/directory is already being previewed - if (previewState.previewedFile === targetPath) { - return; - } - - // Determine if it's a directory or file - let isDir = false; - try { - isDir = fs.lstatSync(targetPath).isDirectory(); - } catch (error) { - isDir = false; // If it doesn't exist, treat as file - } - - // Update the preview with the new file or directory - try { - if (isDir) { - await previewDirectory(targetPath); - } else { - await previewFile(targetPath); - } - } catch (error) { - logger.error("Failed to update preview:", error); - } - - if (previousPreviewedUri) { - oilPreviewProvider.delete(previousPreviewedUri); - } -} - -// Helper function to close the current preview -async function closePreview() { - // Stop listening for cursor movements - if (previewState.cursorListenerDisposable) { - previewState.cursorListenerDisposable.dispose(); - previewState.cursorListenerDisposable = null; - } - - // Close the preview if it's open - if (previewState.previewedFile && previewState.previewUri) { - // For both file and directory previews using OilPreviewFileSystemProvider - const previewUri = previewState.previewUri; - - // Reset state - previewState.previewedFile = null; - previewState.previewedEditor = null; - previewState.isDirectory = false; - previewState.previewUri = null; - - // Close any editors showing this virtual file - for (const editor of vscode.window.visibleTextEditors) { - if ( - editor.document.uri.scheme === OIL_PREVIEW_SCHEME && - editor.document.uri.toString() === previewUri.toString() - ) { - // Close the editor showing the preview - await vscode.commands.executeCommand( - "workbench.action.closeActiveEditor", - editor.document.uri - ); - } - } - - // Clean up the virtual file - try { - oilPreviewProvider.delete(previewUri); - } catch (err) { - logger.error("Failed to delete virtual preview file:", err); - } - } -} - -function oilLineToOilEntry(line: string, path: string): OilEntry { - const parts = line.split(" "); - const identifier = parts[0]; - if (identifier.length !== 4 || identifier[0] !== "/") { - return { - identifier: "", - value: line, - path, - isDir: line.endsWith("/"), - }; - } - const value = parts.slice(1).join(" ").trim(); - const isDir = line.endsWith("/"); - return { - identifier, - value, - path, - isDir, - }; -} - -function oilLinesToOilEntries( - lines: string[], - path: string -): Array<[string, OilEntry]> { - const map = new Array<[string, OilEntry]>(); - for (const line of lines) { - const entry = oilLineToOilEntry(line, path); - if (entry.identifier === "/000") { - continue; - } - if (!entry.value) { - continue; // Skip empty lines - } - map.push([entry.identifier, entry]); - } - return map; -} - -function oilLinesToOilMap( - lines: string[], - path: string -): Map { - const map = new Map(); - for (const line of lines) { - const entry = oilLineToOilEntry(line, path); - if (entry.identifier === "/000") { - continue; - } - map.set(entry.identifier, entry); - } - return map; -} - -function determineChanges(oilState: OilState) { - // Check if there are any pending changes - if (oilState.editedPaths.size > 0) { - // Process the changes - const visitedFiles = Array.from(oilState.visitedPaths.keys()); - const originalFilesMap = new Map(); - visitedFiles.forEach((dir) => { - const lines = oilState.visitedPaths.get(dir); - if (lines) { - const entriesMap = oilLinesToOilMap(lines, dir); - entriesMap.forEach((entry, key) => { - originalFilesMap.set(key, entry); - }); - } - }); - - const movedLines = new Array<[string, string]>(); - const copiedLines = new Array<[string, string]>(); - const addedLines = new Set(); - const deletedLines = new Set(); - - for (const [dirPath, lines] of oilState.editedPaths.entries()) { - const editedEntries = oilLinesToOilEntries(lines, dirPath); - // Check for deleted entries - const fileOriginalEntries = oilLinesToOilEntries( - oilState.visitedPaths.get(dirPath) || [], - dirPath - ); - for (const [key, entry] of fileOriginalEntries) { - const editedEntry = editedEntries.find( - (editedEntry) => - editedEntry[0] === key && editedEntry[1].value === entry.value - ); - if (!editedEntry) { - deletedLines.add(path.join(uriPathToDiskPath(dirPath), entry.value)); - } - } - } - - for (const [dirPath, lines] of oilState.editedPaths.entries()) { - const editedEntries = oilLinesToOilEntries(lines, dirPath); - for (const [key, entry] of editedEntries) { - const originalEntry = originalFilesMap.get(key); - if (originalEntry) { - // Check if the entry has been renamed or moved - if ( - entry.value !== originalEntry.value || - entry.path !== originalEntry.path - ) { - copiedLines.push([ - path.join( - uriPathToDiskPath(originalEntry.path), - originalEntry.value - ), - path.join(uriPathToDiskPath(dirPath), entry.value), - ]); - } - } else { - // New entry added - addedLines.add(path.join(uriPathToDiskPath(dirPath), entry.value)); - } - } - } - // Check for moved entries - // Check for entries that are both copied and deleted (these are moves) - for (const [oldPath, newPath] of copiedLines) { - if (deletedLines.has(oldPath)) { - // This is actually a move operation (copy + delete) - movedLines.push([oldPath, newPath]); - deletedLines.delete(oldPath); // Remove from deletedLines since it's a move, not a delete - } - } - // Filter out copied lines that are actually moves - const filteredCopiedLines = copiedLines.filter(([oldPath, newPath]) => { - // Check if this copy operation is already handled as a move - return !movedLines.some( - ([moveOldPath, moveNewPath]) => - oldPath === moveOldPath && newPath === moveNewPath - ); - }); - - // Replace the original copiedLines with the filtered version - copiedLines.length = 0; - copiedLines.push(...filteredCopiedLines); - - // Find items that are in both addedLines and deletedLines and remove them from both - // This is a workaround for when file identifiers get out of sync - const duplicates = new Set(); - addedLines.forEach((addedItem) => { - if (deletedLines.has(addedItem)) { - duplicates.add(addedItem); - } - }); - - // Remove duplicates from both sets - duplicates.forEach((item) => { - addedLines.delete(item); - deletedLines.delete(item); - }); - - return { - movedLines, - copiedLines, - addedLines, - deletedLines, - }; - } -} - -// Format the path to be relative to the current working directory -function formatPath(path: string): string { - return vscode.workspace.asRelativePath(path); -} - -async function onDidSaveTextDocument(document: vscode.TextDocument) { - // Check if the saved document is our oil file - if (document.uri.scheme === OIL_SCHEME) { - try { - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil state."); - return; - } - const currentPath = getCurrentPath(); - if (!currentPath) { - vscode.window.showErrorMessage("No current path found."); - return; - } - - // Process changes - now we need to handle both current changes - // and any pending changes from navigation - // Read the current content of the file - const currentContent = document.getText(); - const currentLines = currentContent.split(newline); - const currentValue = oilState.visitedPaths.get(currentPath); - if (currentValue?.join("") !== currentLines.join("")) { - oilState.editedPaths.set(currentPath, currentLines); - } - if ( - oilState.editedPaths.has(currentPath) && - oilState.editedPaths.get(currentPath)?.join("") !== - currentLines.join("") - ) { - oilState.editedPaths.set(currentPath, currentLines); - } - - const changes = determineChanges(oilState); - if (!changes) { - return; - } - const { movedLines, copiedLines, addedLines, deletedLines } = changes; - logger.debug("Changes detected:", { - movedLines, - copiedLines, - addedLines: Array.from(addedLines), - deletedLines: Array.from(deletedLines), - }); - - // Check for duplicate destinations and existing files that would be overwritten - const allDestinations = new Set(); - const duplicateDestinations = new Set(); - const existingFileConflicts = new Set(); - - // Check all move operations - movedLines.forEach(([oldPath, newPath]) => { - if (allDestinations.has(newPath)) { - duplicateDestinations.add(newPath); - } else if (fs.existsSync(newPath) && oldPath !== newPath) { - existingFileConflicts.add(newPath); - } - allDestinations.add(newPath); - }); - - // Check all copy operations - copiedLines.forEach(([_, newPath]) => { - if (allDestinations.has(newPath)) { - duplicateDestinations.add(newPath); - } else if (fs.existsSync(newPath)) { - existingFileConflicts.add(newPath); - } - allDestinations.add(newPath); - }); - - // Check all added files/folders - addedLines.forEach((path) => { - if (allDestinations.has(path)) { - duplicateDestinations.add(path); - } else if (fs.existsSync(path)) { - existingFileConflicts.add(path); - } - allDestinations.add(path); - }); - - // Check for duplicate destinations or existing file conflicts - if (duplicateDestinations.size > 0 || existingFileConflicts.size > 0) { - logger.debug( - "Duplicate destinations or existing file conflicts detected:", - { - duplicateDestinations: Array.from(duplicateDestinations), - existingFileConflicts: Array.from(existingFileConflicts), - } - ); - let message = "The following files would be overwritten:\n\n"; - duplicateDestinations.forEach((path) => { - message += `${formatPath(path)}\n`; - }); - existingFileConflicts.forEach((path) => { - message += `${formatPath(path)}\n`; - }); - message += "\nPlease resolve these conflicts before saving."; - vscode.window.showErrorMessage(message, { modal: true }); - oilState.openAfterSave = undefined; - return; - } - - // Check if there are any changes to be made - if ( - movedLines.length === 0 && - copiedLines.length === 0 && - addedLines.size === 0 && - deletedLines.size === 0 - ) { - oilState.openAfterSave = undefined; - return; - } - - // Show confirmation dialog - let message = "The following changes will be applied:\n\n"; - if (movedLines.length > 0) { - movedLines.forEach((item) => { - const [originalPath, newPath] = item; - message += `MOVE ${formatPath(originalPath)} → ${formatPath( - newPath - )}\n`; - }); - } - if (copiedLines.length > 0) { - copiedLines.forEach((item) => { - const [originalPath, newPath] = item; - message += `COPY ${formatPath(originalPath)} → ${formatPath( - newPath - )}\n`; - }); - } - if (addedLines.size > 0) { - addedLines.forEach((item) => { - message += `CREATE ${formatPath(item)}\n`; - }); - } - if (deletedLines.size > 0) { - deletedLines.forEach((item) => { - message += `DELETE ${formatPath(item)}\n`; - }); - } - // Show confirmation dialog - const response = await vscode.window.showWarningMessage( - message, - { modal: true }, - "Yes", - "No" - ); - if (response !== "Yes") { - oilState.openAfterSave = undefined; - return; - } - logger.debug("Processing changes..."); - // Process the changes - // Move files - for (const [oldPath, newPath] of movedLines) { - try { - // Create directory structure if needed - const dirPath = path.dirname(newPath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - // Move the file to the new location - fs.renameSync(oldPath, newPath); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to move file: ${formatPath(oldPath)} to ${newPath.replace( - currentPath + path.sep, - "" - )} - ${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) { - 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}` - ); - } - } - - oilState.editedPaths.clear(); - - // Refresh the directory listing after changes - const updatedContent = await getDirectoryListing(currentPath, oilState); - - // Update the file without triggering the save event again - const edit = new vscode.WorkspaceEdit(); - edit.replace( - document.uri, - new vscode.Range( - new vscode.Position(0, 0), - document.positionAt(document.getText().length) - ), - updatedContent - ); - - const cursorPosition = vscode.window.activeTextEditor?.selection.active; - let cursorOnFileName = ""; - if (cursorPosition) { - // Get file name from the current cursor position - const currentLine = document.lineAt(cursorPosition.line).text; - cursorOnFileName = currentLine.replace(/^\/\d{3} /, "").trim(); - if (cursorOnFileName.includes("/")) { - // If the cursor is on a file with a path, just use the folder name - const parts = cursorOnFileName.split("/"); - cursorOnFileName = parts.at(0) + "/"; - } - } - - await vscode.workspace.applyEdit(edit); - - // Save the document after updating to prevent it from showing as having unsaved changes - await document.save(); - - if (oilState.openAfterSave) { - await select({ overRideLineText: oilState.openAfterSave }); - oilState.openAfterSave = undefined; - return; - } - - // If we are in an oil file, update the cursor position - const activeEditor = vscode.window.activeTextEditor; - if ( - activeEditor && - activeEditor.document.uri.scheme === OIL_SCHEME && - cursorOnFileName - ) { - // Position the cursor on the first line or the file that was just saved - positionCursorOnFile(activeEditor, cursorOnFileName); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to process changes: ${error}`); - } - } -} - -const MAX_EXTENSION_DETECTION_RETRIES = 6; -const EXTENSION_DETECTION_DELAY = 500; // ms - -// Helper function to get setting for disabling vim keymaps -function getDisableVimKeymapsSetting(): boolean { - const config = vscode.workspace.getConfiguration("oil-code"); - return config.get("disableVimKeymaps") || false; -} - -function getDisableOpenCwdNothingOpenSetting(): boolean { - const config = vscode.workspace.getConfiguration("oil-code"); - return config.get("disableOpenCwdNothingOpen") || false; -} - -// Check if Neovim extension is available -async function isNeovimAvailable(): Promise { - try { - // Try to execute a simple command provided by the Neovim extension - await vscode.commands.executeCommand("vscode-neovim.lua", "return 1"); - logger.info("Neovim extension is available"); - return true; - } catch (error) { - // If command execution fails, the extension is likely not available - logger.info("Neovim extension not available or command failed"); - return false; - } -} - -// Check if VSCodeVim extension is available -function isVSCodeVimAvailable(): Promise { - return new Promise((resolve) => { - try { - // VSCodeVim extension adds a "vim" configuration section - const vimConfig = vscode.workspace.getConfiguration("vim"); - - // Check if a known setting exists to confirm the extension is activated - if (vimConfig && vimConfig.has("normalModeKeyBindings")) { - logger.info("VSCodeVim extension is available"); - resolve(true); - } else { - logger.info( - "VSCodeVim extension not available or not fully initialized" - ); - resolve(false); - } - } catch (error) { - logger.error("Error checking VSCodeVim availability:", error); - resolve(false); - } - }); -} - -// Register keymaps for the Neovim extension -async function registerNeovimKeymap(): Promise { - // Check if vim keymaps are disabled in settings - const isDisabled = getDisableVimKeymapsSetting(); - if (isDisabled) { - logger.info("Vim keymaps are disabled in settings."); - return false; - } - - // Check if the extension is available before attempting to register keymaps - if (await isNeovimAvailable()) { - try { - logger.info("Registering Neovim keymaps"); - - // Use the Neovim extension's command API to register Lua code - await vscode.commands.executeCommand("vscode-neovim.lua", oilCodeLua); - - logger.info("Neovim keymaps registered successfully"); - return true; - } catch (error) { - logger.error("Failed to register Neovim keymap:", error); - return false; - } - } - - logger.info("Neovim extension not available, skipping keymap registration"); - return false; -} - -// Register keymaps for the VSCodeVim extension -async function registerVSCodeVimKeymap(): Promise { - // Check if vim keymaps are disabled in settings - const isDisabled = getDisableVimKeymapsSetting(); - if (isDisabled) { - logger.info("Vim keymaps are disabled in settings."); - return false; - } - - // Check if the extension is available before attempting to register keymaps - if (await isVSCodeVimAvailable()) { - try { - logger.info("Registering VSCodeVim keymaps"); - - // Configure VSCodeVim using workspace configuration - const vimConfig = vscode.workspace.getConfiguration("vim"); - const normalModeKeymap = - vimConfig.get("normalModeKeyBindings") || []; - let updatedKeymap = [...normalModeKeymap]; // Make a copy - let keymapChanged = false; - - // Check for and add the Oil open binding if not present - const hasOilOpenBinding = normalModeKeymap.some((binding) => - binding.commands?.some( - (cmd: { command: string }) => cmd.command === "oil-code.open" - ) - ); - - if (!hasOilOpenBinding) { - updatedKeymap.push({ - before: ["-"], - commands: [{ command: "oil-code.open" }], - }); - keymapChanged = true; - } - - // Check for and add the Oil select binding if not present - const hasOilSelectBinding = normalModeKeymap.some((binding) => - binding.commands?.some( - (cmd: { command: string }) => cmd.command === "oil-code.select" - ) - ); - - if (!hasOilSelectBinding) { - updatedKeymap.push({ - before: [""], - commands: [{ command: "oil-code.select" }], - }); - keymapChanged = true; - } - - // Check for and add the Oil refresh binding if not present - const hasOilRefreshBinding = normalModeKeymap.some((binding) => - binding.commands?.some( - (cmd: { command: string }) => cmd.command === "oil-code.refresh" - ) - ); - - if (!hasOilRefreshBinding) { - updatedKeymap.push({ - before: [""], - commands: [{ command: "oil-code.refresh" }], - }); - keymapChanged = true; - } - - // Check for and add the Oil cd binding if not present - const hasOilCdBinding = normalModeKeymap.some((binding) => - binding.commands?.some( - (cmd: { command: string }) => cmd.command === "oil-code.cd" - ) - ); - if (!hasOilCdBinding) { - updatedKeymap.push({ - before: ["`"], - commands: [{ command: "oil-code.cd" }], - }); - keymapChanged = true; - } - - // Update the configuration if changes were made - if (keymapChanged) { - await vimConfig.update( - "normalModeKeyBindings", - updatedKeymap, - vscode.ConfigurationTarget.Global - ); - logger.info("VSCodeVim keymaps updated successfully"); - } else { - logger.info("VSCodeVim keymaps already configured"); - } - - return true; - } catch (error) { - logger.error("Failed to register VSCodeVim keymap:", error); - return false; - } - } - - logger.info( - "VSCodeVim extension not available, skipping keymap registration" - ); - return false; -} - -// Function to attempt registering vim keymaps with retries -async function attemptRegisteringVimKeymaps( - retries: number = MAX_EXTENSION_DETECTION_RETRIES, - delay: number = EXTENSION_DETECTION_DELAY -): Promise { - let neovimRegistered = false; - let vscodevimRegistered = false; - - for (let attempt = 0; attempt <= retries; attempt++) { - if (attempt > 0) { - logger.info( - `Retry attempt ${attempt} of ${retries} to register Vim keymaps` - ); - // Wait before retry - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - // Try to register Neovim keymaps if not already registered - if (!neovimRegistered) { - neovimRegistered = await registerNeovimKeymap(); - } - - // Try to register VSCodeVim keymaps if not already registered - if (!vscodevimRegistered) { - vscodevimRegistered = await registerVSCodeVimKeymap(); - } - - // If both are registered or we've exhausted attempts, we're done - if (neovimRegistered || vscodevimRegistered) { - logger.info( - "Successfully registered keymaps for all available Vim extensions" - ); - break; - } - } - - if (!neovimRegistered && !vscodevimRegistered) { - logger.info("No Vim extensions were detected after all retry attempts"); - } -} - -async function preview(overrideEnabled: boolean = false) { - const activeEditor = vscode.window.activeTextEditor; - - if (!activeEditor) { - vscode.window.showErrorMessage("No active editor found."); - return; - } - - // Check if the current file is our oil temp file - if (activeEditor.document.uri.scheme !== OIL_SCHEME) { - return; - } - - const document = activeEditor.document; - const cursorPosition = activeEditor.selection.active; - const lineText = document.lineAt(cursorPosition.line).text; - const fileName = lineText.replace(/^\/\d{3} /, "").trim(); - - if (!fileName) { - vscode.window.showInformationMessage( - "No file or directory found under cursor." - ); - return; - } - - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil current directory."); - return; - } - - const currentFolderPath = getCurrentPath(); - if (!currentFolderPath) { - vscode.window.showErrorMessage("No current folder path found."); - return; - } - - let targetPath: string; - - // Handle "../" special case - if (fileName === "../") { - targetPath = path.dirname(uriPathToDiskPath(currentFolderPath)); - } else { - targetPath = path.join(uriPathToDiskPath(currentFolderPath), fileName); - } - - if (!fs.existsSync(targetPath)) { - vscode.window.showErrorMessage(`"${fileName}" does not exist.`); - return; - } - - const isDir = fs.lstatSync(targetPath).isDirectory(); - - // If this file/directory is already being previewed, close the preview (toggle behavior) - if (previewState.previewedFile === targetPath) { - previewState.previewEnabled = overrideEnabled; - if (!overrideEnabled) { - await closePreview(); - } - return; - } - previewState.previewEnabled = true; - - // Preview differently based on whether it's a file or directory - if (isDir) { - await previewDirectory(targetPath); - } else { - await previewFile(targetPath); - } -} - -// Change the VSCode working directory to current oil directory -async function changeDirectory() { - const oilState = getOilState(); - if (!oilState) { - vscode.window.showErrorMessage("Failed to get oil state."); - return; - } - const currentPath = getCurrentPath(); - if (!currentPath) { - vscode.window.showErrorMessage("No current path found."); - return; - } - - // Check if we have pending changes - if ( - oilState.editedPaths.size > 0 || - vscode.window.activeTextEditor?.document.isDirty - ) { - const result = await vscode.window.showWarningMessage( - "Discard changes?", - { modal: true }, - "Yes" - ); - if (result !== "Yes") { - return; - } - } - - const currentPathDisk = uriPathToDiskPath(currentPath); - // Update VS Code's workspace folders - try { - const folderUri = vscode.Uri.file(currentPathDisk); - - // Update the first workspace folder to the new location - // Open the new directory instead of updating workspace folders - await vscode.commands.executeCommand("vscode.openFolder", folderUri, { - forceReuseWindow: false, - }); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to change working directory: ${error}` - ); - } -} - -let helpPanel: vscode.WebviewPanel | null = null; - -// Displays helpful information about oil commands that are available -async function showHelp() { - helpPanel?.dispose(); // Close any existing help panel - const helpHeaders = [ - "Command", - "Vim Key Binding", - "Default Shortcut", - "Description", - ]; - // Create a table of commands and default keymaps - const helpTable = [ - ["open", "-", "alt+-", "Open oil from the currents file parent directory"], - ["help", "", "alt+shift+h", "Show this help information"], - ["close", "", "alt+c", "Close oil explorer"], - ["select", "Enter", "alt+Enter", "Open selected file/directory"], - ["selectTab", "ctrl+t", "alt+t", "Open selected file in a new tab"], - ["selectVertical", "", "alt+s", "Open selected file in a vertical split"], - ["openParent", "-", "alt+-", "Navigate to parent directory"], - ["openCwd", "_", "alt_shift+-", "Navigate to workspace root"], - ["preview", "ctrl+p", "alt+p", "Preview file/directory at cursor"], - ["refresh", "ctrl+l", "alt+l", "Refresh current directory view"], - [ - "cd", - "`", - "alt+`", - "Change VSCode working directory to current oil directory", - ], - ]; - - // Display the message in a Markdown preview panel - const panel = vscode.window.createWebviewPanel( - "oilHelp", - "Oil Help", - vscode.ViewColumn.Active, - { - enableScripts: false, - } - ); - - panel.webview.html = ` - - - - - - -
-

Oil Help

-

Oil.code is a file explorer for VSCode that allows you to navigate and manage files and directories directly in your editor window.

- - - - ${helpHeaders.map((header) => ``).join("")} - - ${helpTable - .map( - (row) => - `${row.map((cell) => ``).join("")}` - ) - .join("")} -
Available Commands with default keybinding/shortcut
${header}
${cell}
- - -

If you find oil.code useful, please consider starring the repository on GitHub and reviewing it on the VS Code Marketplace.

-
- - `; - - helpPanel = panel; -} +import { activateDecorations } from "./decorations"; +import { + OIL_SCHEME, + OIL_PREVIEW_SCHEME, + MAX_EXTENSION_DETECTION_RETRIES, + EXTENSION_DETECTION_DELAY, +} from "./constants"; +import { resetPreviewState } from "./state/previewState"; +import { oilFileProvider, oilPreviewProvider } from "./providers/providers"; +import { getDisableOpenCwdNothingOpenSetting } from "./utils/settings"; +import { attemptRegisteringVimKeymaps } from "./vim/vimKeymaps"; +import { onDidChangeActiveTextEditor } from "./handlers/onDidChangeActiveTextEditor"; +import { onDidSaveTextDocument } from "./handlers/onDidSaveTextDocument"; +import { closePreview } from "./commands/preview"; + +// Commands +import { openOil } from "./commands/openOil"; +import { help } from "./commands/help"; +import { closeOil } from "./commands/close"; +import { select } from "./commands/select"; +import { openParent } from "./commands/openParent"; +import { openCwd } from "./commands/openCwd"; +import { preview } from "./commands/preview"; +import { refresh } from "./commands/refresh"; +import { cd } from "./commands/cd"; +import { logger } from "./logger"; // In your extension's activate function export function activate(context: vscode.ExtensionContext) { @@ -1986,14 +50,7 @@ export function activate(context: vscode.ExtensionContext) { activateDecorations(context); // Reset preview state - previewState = { - previewedFile: null, - previewedEditor: null, - cursorListenerDisposable: null, - isDirectory: false, - previewUri: null, - previewEnabled: false, - }; + resetPreviewState(); // Set up listener for extension changes (activation/deactivation) const extensionChangeListener = vscode.extensions.onDidChange(() => { @@ -2009,7 +66,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.onDidChangeActiveTextEditor(onDidChangeActiveTextEditor), vscode.workspace.onDidSaveTextDocument(onDidSaveTextDocument), vscode.commands.registerCommand("oil-code.open", openOil), - vscode.commands.registerCommand("oil-code.help", showHelp), + vscode.commands.registerCommand("oil-code.help", help), vscode.commands.registerCommand("oil-code.close", closeOil), vscode.commands.registerCommand("oil-code.select", select), vscode.commands.registerCommand("oil-code.selectVertical", () => @@ -2022,7 +79,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("oil-code.openCwd", openCwd), vscode.commands.registerCommand("oil-code.preview", preview), vscode.commands.registerCommand("oil-code.refresh", refresh), - vscode.commands.registerCommand("oil-code.cd", changeDirectory) + vscode.commands.registerCommand("oil-code.cd", cd) ); // Make initial attempt to register Vim keymaps with retries diff --git a/src/handlers/onDidChangeActiveTextEditor.ts b/src/handlers/onDidChangeActiveTextEditor.ts new file mode 100644 index 0000000..7d1b8bf --- /dev/null +++ b/src/handlers/onDidChangeActiveTextEditor.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode"; +import { OIL_SCHEME, OIL_PREVIEW_SCHEME } from "../constants"; +import { getOilState } from "../state/oilState"; +import { closePreview } from "../commands/preview"; +import { checkAndEnableAutoSave } from "../utils/settings"; +import { getPreviewState } from "../state/previewState"; + +export async function onDidChangeActiveTextEditor( + editor: vscode.TextEditor | undefined +) { + if (!editor) { + return; + } + const oilState = getOilState(); + + // Close preview when leaving oil view + if ( + ![OIL_SCHEME, OIL_PREVIEW_SCHEME].includes(editor.document.uri.scheme) && + getPreviewState().previewedFile + ) { + await closePreview(); + } + + // This was triggering too often, causing issues with preview updates + // If we are returning to an oil file and preview is enabled, update the preview + // if ( + // editor.document.uri.scheme === OIL_SCHEME && + // getPreviewState().previewEnabled + // ) { + // await preview(true); + // } + + if (!oilState) { + await checkAndEnableAutoSave(); + return; + } +} diff --git a/src/handlers/onDidSaveTextDocument.ts b/src/handlers/onDidSaveTextDocument.ts new file mode 100644 index 0000000..c88eafb --- /dev/null +++ b/src/handlers/onDidSaveTextDocument.ts @@ -0,0 +1,316 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { OIL_SCHEME } from "../constants"; +import { getOilState, getCurrentPath } from "../state/oilState"; +import { determineChanges, positionCursorOnFile } from "../utils/oilUtils"; +import { formatPath, uriPathToDiskPath } from "../utils/pathUtils"; +import { + getDirectoryListing, + removeDirectoryRecursively, +} from "../utils/fileUtils"; +import { select } from "../commands/select"; +import { newline } from "../newline"; +import { logger } from "../logger"; + +export async function onDidSaveTextDocument(document: vscode.TextDocument) { + // Check if the saved document is our oil file + if (document.uri.scheme === OIL_SCHEME) { + try { + const oilState = getOilState(); + if (!oilState) { + vscode.window.showErrorMessage("Failed to get oil state."); + return; + } + const currentPath = getCurrentPath(); + if (!currentPath) { + vscode.window.showErrorMessage("No current path found."); + return; + } + + // Process changes - now we need to handle both current changes + // and any pending changes from navigation + // Read the current content of the file + const currentContent = document.getText(); + const currentLines = currentContent.split(newline); + const currentValue = oilState.visitedPaths.get(currentPath); + if (currentValue?.join("") !== currentLines.join("")) { + oilState.editedPaths.set(currentPath, currentLines); + } + if ( + oilState.editedPaths.has(currentPath) && + oilState.editedPaths.get(currentPath)?.join("") !== + currentLines.join("") + ) { + oilState.editedPaths.set(currentPath, currentLines); + } + + const changes = determineChanges(oilState); + if (!changes) { + return; + } + const { movedLines, copiedLines, addedLines, deletedLines } = changes; + logger.debug("Changes detected:", { + movedLines, + copiedLines, + addedLines: Array.from(addedLines), + deletedLines: Array.from(deletedLines), + }); + + // Check for duplicate destinations and existing files that would be overwritten + const allDestinations = new Set(); + const duplicateDestinations = new Set(); + const existingFileConflicts = new Set(); + + // Check all move operations + movedLines.forEach(([oldPath, newPath]) => { + if (allDestinations.has(newPath)) { + duplicateDestinations.add(newPath); + } else if (fs.existsSync(newPath) && oldPath !== newPath) { + existingFileConflicts.add(newPath); + } + allDestinations.add(newPath); + }); + + // Check all copy operations + copiedLines.forEach(([_, newPath]) => { + if (allDestinations.has(newPath)) { + duplicateDestinations.add(newPath); + } else if (fs.existsSync(newPath)) { + existingFileConflicts.add(newPath); + } + allDestinations.add(newPath); + }); + + // Check all added files/folders + addedLines.forEach((path) => { + if (allDestinations.has(path)) { + duplicateDestinations.add(path); + } else if (fs.existsSync(path)) { + existingFileConflicts.add(path); + } + allDestinations.add(path); + }); + + // Check for duplicate destinations or existing file conflicts + if (duplicateDestinations.size > 0 || existingFileConflicts.size > 0) { + logger.debug( + "Duplicate destinations or existing file conflicts detected:", + { + duplicateDestinations: Array.from(duplicateDestinations), + existingFileConflicts: Array.from(existingFileConflicts), + } + ); + let message = "The following files would be overwritten:\n\n"; + duplicateDestinations.forEach((path) => { + message += `${formatPath(path)}\n`; + }); + existingFileConflicts.forEach((path) => { + message += `${formatPath(path)}\n`; + }); + message += "\nPlease resolve these conflicts before saving."; + vscode.window.showErrorMessage(message, { modal: true }); + oilState.openAfterSave = undefined; + return; + } + + // Check if there are any changes to be made + if ( + movedLines.length === 0 && + copiedLines.length === 0 && + addedLines.size === 0 && + deletedLines.size === 0 + ) { + oilState.openAfterSave = undefined; + return; + } + + // Show confirmation dialog + let message = "The following changes will be applied:\n\n"; + if (movedLines.length > 0) { + movedLines.forEach((item) => { + const [originalPath, newPath] = item; + message += `MOVE ${formatPath(originalPath)} → ${formatPath( + newPath + )}\n`; + }); + } + if (copiedLines.length > 0) { + copiedLines.forEach((item) => { + const [originalPath, newPath] = item; + message += `COPY ${formatPath(originalPath)} → ${formatPath( + newPath + )}\n`; + }); + } + if (addedLines.size > 0) { + addedLines.forEach((item) => { + message += `CREATE ${formatPath(item)}\n`; + }); + } + if (deletedLines.size > 0) { + deletedLines.forEach((item) => { + message += `DELETE ${formatPath(item)}\n`; + }); + } + // Show confirmation dialog + const response = await vscode.window.showWarningMessage( + message, + { modal: true }, + "Yes", + "No" + ); + if (response !== "Yes") { + oilState.openAfterSave = undefined; + return; + } + logger.debug("Processing changes..."); + // Process the changes + // Move files + for (const [oldPath, newPath] of movedLines) { + try { + // Create directory structure if needed + const dirPath = path.dirname(newPath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + // Move the file to the new location + fs.renameSync(oldPath, newPath); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to move file: ${formatPath(oldPath)} to ${newPath.replace( + currentPath + path.sep, + "" + )} - ${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) { + 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}` + ); + } + } + + oilState.editedPaths.clear(); + + // Refresh the directory listing after changes + const updatedContent = await getDirectoryListing( + uriPathToDiskPath(currentPath), + oilState + ); + + // Update the file without triggering the save event again + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range( + new vscode.Position(0, 0), + document.positionAt(document.getText().length) + ), + updatedContent + ); + + const cursorPosition = vscode.window.activeTextEditor?.selection.active; + let cursorOnFileName = ""; + if (cursorPosition) { + // Get file name from the current cursor position + const currentLine = document.lineAt(cursorPosition.line).text; + cursorOnFileName = currentLine.replace(/^\/\d{3} /, "").trim(); + if (cursorOnFileName.includes("/")) { + // If the cursor is on a file with a path, just use the folder name + const parts = cursorOnFileName.split("/"); + cursorOnFileName = parts.at(0) + "/"; + } + } + + await vscode.workspace.applyEdit(edit); + + // Save the document after updating to prevent it from showing as having unsaved changes + await document.save(); + + if (oilState.openAfterSave) { + await select({ overRideLineText: oilState.openAfterSave }); + oilState.openAfterSave = undefined; + return; + } + + // If we are in an oil file, update the cursor position + const activeEditor = vscode.window.activeTextEditor; + if ( + activeEditor && + activeEditor.document.uri.scheme === OIL_SCHEME && + cursorOnFileName + ) { + // Position the cursor on the first line or the file that was just saved + positionCursorOnFile(activeEditor, cursorOnFileName); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to process changes: ${error}`); + } + } +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..3517f00 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,3 @@ +import * as vscode from 'vscode'; + +export const logger = vscode.window.createOutputChannel("oil.code", { log: true }); \ No newline at end of file diff --git a/src/providers/OilFileSystemProvider.ts b/src/providers/OilFileSystemProvider.ts new file mode 100644 index 0000000..3c4889f --- /dev/null +++ b/src/providers/OilFileSystemProvider.ts @@ -0,0 +1,70 @@ +import * as vscode from "vscode"; +import { getDirectoryListing } from "../utils/fileUtils"; +import { getOilState, setOilState } from "../state/oilState"; +import { oilUriToDiskPath } from "../utils/pathUtils"; +import { initOilState } from "../state/initState"; +import { logger } from "../logger"; + +export class OilFileSystemProvider implements vscode.FileSystemProvider { + private readonly _onDidChangeFile = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private _documents = new Map(); + + // Create or update an in-memory document + writeFile(_uri: vscode.Uri, _content: Uint8Array): void {} + + // Read an in-memory document + async readFile(uri: vscode.Uri): Promise { + logger.trace("Reading file from OilFileSystemProvider:", uri.toString()); + let oilState = getOilState(); + if (!oilState) { + const newOilState = initOilState(); + setOilState(newOilState); + oilState = newOilState; + this._documents.clear(); // Clear cache on new state + } + const content = this._documents.get(uri.toString()); + if (content) { + return content; + } + const folderPath = oilUriToDiskPath(uri); + const directoryContent = await getDirectoryListing(folderPath, oilState); + if (!directoryContent) { + throw vscode.FileSystemError.FileNotFound(uri); + } + const buffer = Buffer.from(directoryContent); + this._documents.set(uri.toString(), buffer); + return buffer; + } + + // Delete an in-memory document + delete(uri: vscode.Uri): void { + this._documents.delete(uri.toString()); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + // Required methods for FileSystemProvider interface + watch(_uri: vscode.Uri): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(_uri: vscode.Uri): vscode.FileStat { + return { + type: vscode.FileType.File, + ctime: Date.now(), + mtime: Date.now(), + size: 0, + }; + } + + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + return []; + } + + createDirectory(_uri: vscode.Uri): void {} + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void {} +} diff --git a/src/providers/OilPreviewFileSystemProvider.ts b/src/providers/OilPreviewFileSystemProvider.ts new file mode 100644 index 0000000..e81602e --- /dev/null +++ b/src/providers/OilPreviewFileSystemProvider.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode"; + +export class OilPreviewFileSystemProvider implements vscode.FileSystemProvider { + private readonly _onDidChangeFile = new vscode.EventEmitter< + vscode.FileChangeEvent[] + >(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private _documents = new Map(); + + // Create or update an in-memory document + writeFile(uri: vscode.Uri, content: Uint8Array): void { + this._documents.set(uri.toString(), content); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + // Read an in-memory document + readFile(uri: vscode.Uri): Uint8Array { + const content = this._documents.get(uri.toString()); + if (content) { + return content; + } + return new Uint8Array(); + } + + // Delete an in-memory document + delete(uri: vscode.Uri): void { + this._documents.delete(uri.toString()); + this._onDidChangeFile.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + // Required methods for FileSystemProvider interface + watch(_uri: vscode.Uri): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(_uri: vscode.Uri): vscode.FileStat { + return { + type: vscode.FileType.File, + ctime: Date.now(), + mtime: Date.now(), + size: 0, + }; + } + + readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] { + return []; + } + + createDirectory(_uri: vscode.Uri): void {} + + rename(_oldUri: vscode.Uri, _newUri: vscode.Uri): void {} +} diff --git a/src/providers/providers.ts b/src/providers/providers.ts new file mode 100644 index 0000000..3abf1d3 --- /dev/null +++ b/src/providers/providers.ts @@ -0,0 +1,5 @@ +import { OilFileSystemProvider } from "./OilFileSystemProvider"; +import { OilPreviewFileSystemProvider } from "./OilPreviewFileSystemProvider"; + +export const oilFileProvider = new OilFileSystemProvider(); +export const oilPreviewProvider = new OilPreviewFileSystemProvider(); diff --git a/src/state/initState.ts b/src/state/initState.ts new file mode 100644 index 0000000..5fdc8bf --- /dev/null +++ b/src/state/initState.ts @@ -0,0 +1,45 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { OilState, OIL_SCHEME } from "../constants"; +import { normalizePathToUri } from "../utils/pathUtils"; + +export function initOilState(): OilState { + const activeEditor = vscode.window.activeTextEditor; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + + let currentOrWorkspacePath = workspaceFolder?.uri.fsPath || ""; + + if (activeEditor && activeEditor.document.uri.scheme === "file") { + const filePath = activeEditor.document.uri.fsPath; + const dirPath = path.dirname(filePath); + currentOrWorkspacePath = dirPath; + } + + const normalizedPath = normalizePathToUri(currentOrWorkspacePath); + const tempFileUri = vscode.Uri.parse(`${OIL_SCHEME}://oil${normalizedPath}`); + + const newState: OilState = { + tempFileUri: tempFileUri, + currentPath: currentOrWorkspacePath, // Use the actual disk path, not normalized + identifierCounter: 1, + visitedPaths: new Map(), + editedPaths: new Map(), + }; + + return newState; +} + +export function initOilStateWithPath(path: string): OilState { + const normalizedPath = normalizePathToUri(path); + const tempFileUri = vscode.Uri.parse(`${OIL_SCHEME}://oil${normalizedPath}`); + + const newState: OilState = { + tempFileUri: tempFileUri, + currentPath: path, // Use the actual disk path, not normalized + identifierCounter: 1, + visitedPaths: new Map(), + editedPaths: new Map(), + }; + + return newState; +} diff --git a/src/state/oilState.ts b/src/state/oilState.ts new file mode 100644 index 0000000..e9c4ac7 --- /dev/null +++ b/src/state/oilState.ts @@ -0,0 +1,32 @@ +import * as vscode from "vscode"; +import { OIL_PREVIEW_SCHEME, OIL_SCHEME, OilState } from "../constants"; + +let oilState: OilState | undefined; + +export function getOilState(): OilState | undefined { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && oilState) { + const scheme = activeEditor.document.uri.scheme; + if (scheme === OIL_SCHEME || scheme === OIL_PREVIEW_SCHEME) { + return oilState; + } + } + return undefined; +} + +export function setOilState(state: OilState | undefined) { + oilState = state; +} + +export function getCurrentPath(): string | undefined { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && oilState) { + const documentUri = activeEditor.document.uri; + const scheme = activeEditor.document.uri.scheme; + // Check if the scheme is either "oil" or "oil-preview" + if (scheme === OIL_SCHEME || scheme === OIL_PREVIEW_SCHEME) { + return documentUri.path; + } + } + return undefined; +} diff --git a/src/state/previewState.ts b/src/state/previewState.ts new file mode 100644 index 0000000..c7518a1 --- /dev/null +++ b/src/state/previewState.ts @@ -0,0 +1,30 @@ +import { PreviewState } from "../constants"; + +let previewState: PreviewState = { + previewedFile: null, + previewedEditor: null, + cursorListenerDisposable: null, + isDirectory: false, + previewUri: null, + previewEnabled: false, +}; + +export function getPreviewState(): PreviewState { + return previewState; +} + +export function setPreviewState(state: PreviewState) { + previewState = state; +} + +export function resetPreviewState() { + previewState.cursorListenerDisposable?.dispose(); + previewState = { + previewedFile: null, + previewedEditor: null, + cursorListenerDisposable: null, + isDirectory: false, + previewUri: null, + previewEnabled: false, + }; +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index e20cd86..9408d37 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -678,6 +678,7 @@ suite("oil.code", () => { ), Buffer.from(testContent, "utf-8") ); + await vscode.commands.executeCommand("oil-code.open"); await waitForDocumentText(["/000 ../", "/001 oil-dir/"]); @@ -685,8 +686,8 @@ suite("oil.code", () => { assert.ok(editor, "No active editor"); // Move cursor to the file name - const position = new vscode.Position(1, 0); - editor.selection = new vscode.Selection(position, position); + await sleep(100); + editor.selection = new vscode.Selection(1, 5, 1, 5); await vscode.commands.executeCommand("oil-code.preview"); await waitFor(() => { @@ -708,6 +709,8 @@ suite("oil.code", () => { ); }); + await sleep(100); + await vscode.commands.executeCommand("oil-code.select"); await sleep(100); @@ -739,10 +742,9 @@ suite("oil.code", () => { }); // Move cursor to the file name - const editor2 = vscode.window.activeTextEditor; - assert.ok(editor2, "No active editor"); - const position2 = new vscode.Position(1, 0); - editor2.selection = new vscode.Selection(position2, position2); + const editor3 = vscode.window.activeTextEditor; + assert.ok(editor3, "No active editor"); + editor3.selection = new vscode.Selection(1, 5, 1, 5); await sleep(100); diff --git a/src/test/utils/waitFor.ts b/src/test/utils/waitFor.ts index 08f52bb..0d3a49c 100644 --- a/src/test/utils/waitFor.ts +++ b/src/test/utils/waitFor.ts @@ -1,5 +1,6 @@ import * as assert from "assert"; import { tryCatch } from "../../tryCatch"; +import { error } from "console"; export async function waitFor( assertion: () => void, @@ -14,8 +15,11 @@ export async function waitFor( const timeout = options?.timeout || 3000; const interval = options?.interval || 100; const endTime = startTime + timeout; - const [_, error] = tryCatch(() => assertion()); - while (error) { + while (true) { + const [_, error] = tryCatch(assertion); + if (!error) { + return; + } if (Date.now() > endTime) { if (options?.onTimeout) { assert.fail(options.onTimeout()); diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts new file mode 100644 index 0000000..e670eb3 --- /dev/null +++ b/src/utils/fileUtils.ts @@ -0,0 +1,110 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { GO_UP_IDENTIFIER, OilState } from "../constants"; +import { removeTrailingSlash, normalizePathToUri } from "./pathUtils"; +import { newline } from "../newline"; + +export async function getDirectoryListing( + folderPath: string, + oilState: OilState, + preview: boolean = false +): Promise { + let pathUri = vscode.Uri.file(folderPath); + + const folderPathUri = removeTrailingSlash(normalizePathToUri(folderPath)); + if (oilState.editedPaths.has(folderPathUri)) { + return oilState.editedPaths.get(folderPathUri)!.join(newline); + } + + if ( + oilState.editedPaths.size > 0 && + oilState.visitedPaths.has(folderPathUri) + ) { + // If we have visited this path before, return the cached listing + return oilState.visitedPaths.get(folderPathUri)!.join(newline); + } + + let results = await vscode.workspace.fs.readDirectory(pathUri); + + results.sort(([aName, aType], [bName, bType]) => { + return aType & vscode.FileType.Directory + ? bType & vscode.FileType.Directory + ? 0 + : -1 + : aName < bName + ? -1 + : 1; + }); + + let listings = results.map(([name, type]) => { + return type & vscode.FileType.Directory ? `${name}/` : name; + }); + + let hasParent = path.dirname(folderPath) !== folderPath; + if (hasParent) { + listings.unshift("../"); + } + + let existingFiles = new Map(); + // Diff against the cached version and remove any deleted files and add new ones + if (oilState.visitedPaths.has(folderPathUri)) { + const previousListings = oilState.visitedPaths.get(folderPathUri)!; + for (const file of previousListings) { + const fileName = file.slice(4); + if (listings.includes(fileName)) { + existingFiles.set(fileName, file); + } + } + } + + // Generate listings with hidden identifiers using global counter + let listingsWithIds = listings.map((name) => { + if (name === "../") { + return `${GO_UP_IDENTIFIER} ${name}`; + } + if (existingFiles.has(name)) { + return existingFiles.get(name)!; + } + + // Use and increment the global counter for each file/directory + const identifier = preview + ? GO_UP_IDENTIFIER + : `/${oilState.identifierCounter.toString().padStart(3, "0")}`; + + if (!preview) { + oilState.identifierCounter++; + } + + return `${identifier} ${name}`; + }); + + oilState.visitedPaths.set(folderPathUri, listingsWithIds); + + return listingsWithIds.join(newline); +} + +export async function removeDirectoryRecursively( + dirPath: string +): Promise { + if (!fs.existsSync(dirPath)) { + return; + } + + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + // Recursively remove subdirectories + await removeDirectoryRecursively(fullPath); + } else { + // Remove files + fs.unlinkSync(fullPath); + } + } + + // Remove the now-empty directory + fs.rmdirSync(dirPath); +} diff --git a/src/utils/oilUtils.ts b/src/utils/oilUtils.ts new file mode 100644 index 0000000..2cb0af9 --- /dev/null +++ b/src/utils/oilUtils.ts @@ -0,0 +1,231 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { GO_UP_IDENTIFIER, OilEntry, OilState } from "../constants"; +import { newline } from "../newline"; + +export function oilLineToOilEntry(line: string, path: string): OilEntry { + const parts = line.split(" "); + const identifier = parts[0]; + if (identifier.length !== 4 || identifier[0] !== "/") { + return { + identifier: "", + value: line, + path, + isDir: line.endsWith("/"), + }; + } + const value = parts.slice(1).join(" ").trim(); + const isDir = line.endsWith("/"); + return { + identifier, + value, + path, + isDir, + }; +} + +export function oilLinesToOilEntries( + lines: string[], + path: string +): Array<[string, OilEntry]> { + const map = new Array<[string, OilEntry]>(); + for (const line of lines) { + const entry = oilLineToOilEntry(line, path); + if (entry.identifier === GO_UP_IDENTIFIER) { + continue; + } + if (!entry.value) { + continue; // Skip empty lines + } + map.push([entry.identifier, entry]); + } + return map; +} + +export function oilLinesToOilMap( + lines: string[], + path: string +): Map { + const map = new Map(); + for (const line of lines) { + const entry = oilLineToOilEntry(line, path); + if (entry.identifier === GO_UP_IDENTIFIER) { + continue; + } + map.set(entry.identifier, entry); + } + return map; +} + +export function positionCursorOnFile( + editor: vscode.TextEditor, + fileName: string +) { + if (!editor) { + return; + } + + const document = editor.document; + const text = document.getText(); + const lines = text.split(newline); + + // If no filename is provided or it's going up a directory, place cursor on first line + if (!fileName || fileName === "../") { + editor.selection = new vscode.Selection(0, 0, 0, 0); + editor.revealRange(new vscode.Range(0, 0, 0, 0)); + return; + } + + // Find the line number of the file + for (let i = 0; i < lines.length; i++) { + // Extract name without hidden identifier + const lineName = lines[i].replace(/^\/\d{3} /, "").trim(); + if (lineName === fileName || lineName === `${fileName}/`) { + // Position cursor at the beginning of the line + editor.selection = new vscode.Selection(i, 0, i, 0); + editor.revealRange(new vscode.Range(i, 0, i, 0)); + return; + } + } + + // If file not found, position on first line + editor.selection = new vscode.Selection(0, 0, 0, 0); + editor.revealRange(new vscode.Range(0, 0, 0, 0)); +} + +export function hasPendingChanges(oilState: OilState | undefined): boolean { + if (!oilState) { + return false; + } + return oilState.editedPaths.size > 0; +} + +export function checkForVisitedCleanup(oilState: OilState) { + if ( + vscode.window.visibleTextEditors.filter( + (editor) => editor.document.languageId === "oil" + ).length === 1 && + !oilState.editedPaths.size + ) { + oilState.visitedPaths.clear(); + oilState.identifierCounter = 1; + } +} + +export function determineChanges(oilState: OilState) { + // Check if there are any pending changes + if (oilState.editedPaths.size > 0) { + // Process the changes + const visitedFiles = Array.from(oilState.visitedPaths.keys()); + const originalFilesMap = new Map(); + visitedFiles.forEach((dir) => { + const lines = oilState.visitedPaths.get(dir); + if (lines) { + const entriesMap = oilLinesToOilMap(lines, dir); + entriesMap.forEach((entry, key) => { + originalFilesMap.set(key, entry); + }); + } + }); + + const movedLines = new Array<[string, string]>(); + const copiedLines = new Array<[string, string]>(); + const addedLines = new Set(); + const deletedLines = new Set(); + + for (const [dirPath, lines] of oilState.editedPaths.entries()) { + const editedEntries = oilLinesToOilEntries(lines, dirPath); + // Check for deleted entries + const fileOriginalEntries = oilLinesToOilEntries( + oilState.visitedPaths.get(dirPath) || [], + dirPath + ); + for (const [key, entry] of fileOriginalEntries) { + const editedEntry = editedEntries.find( + (editedEntry) => + editedEntry[0] === key && editedEntry[1].value === entry.value + ); + if (!editedEntry) { + deletedLines.add(path.join(uriPathToDiskPath(dirPath), entry.value)); + } + } + } + + for (const [dirPath, lines] of oilState.editedPaths.entries()) { + const editedEntries = oilLinesToOilEntries(lines, dirPath); + for (const [key, entry] of editedEntries) { + const originalEntry = originalFilesMap.get(key); + if (originalEntry) { + // Check if the entry has been renamed or moved + if ( + entry.value !== originalEntry.value || + entry.path !== originalEntry.path + ) { + copiedLines.push([ + path.join( + uriPathToDiskPath(originalEntry.path), + originalEntry.value + ), + path.join(uriPathToDiskPath(dirPath), entry.value), + ]); + } + } else { + // New entry added + addedLines.add(path.join(uriPathToDiskPath(dirPath), entry.value)); + } + } + } + // Check for moved entries + // Check for entries that are both copied and deleted (these are moves) + for (const [oldPath, newPath] of copiedLines) { + if (deletedLines.has(oldPath)) { + // This is actually a move operation (copy + delete) + movedLines.push([oldPath, newPath]); + deletedLines.delete(oldPath); // Remove from deletedLines since it's a move, not a delete + } + } + // Filter out copied lines that are actually moves + const filteredCopiedLines = copiedLines.filter(([oldPath, newPath]) => { + // Check if this copy operation is already handled as a move + return !movedLines.some( + ([moveOldPath, moveNewPath]) => + oldPath === moveOldPath && newPath === moveNewPath + ); + }); + + // Replace the original copiedLines with the filtered version + copiedLines.length = 0; + copiedLines.push(...filteredCopiedLines); + + // Find items that are in both addedLines and deletedLines and remove them from both + // This is a workaround for when file identifiers get out of sync + const duplicates = new Set(); + addedLines.forEach((addedItem) => { + if (deletedLines.has(addedItem)) { + duplicates.add(addedItem); + } + }); + + // Remove duplicates from both sets + duplicates.forEach((item) => { + addedLines.delete(item); + deletedLines.delete(item); + }); + + return { + movedLines, + copiedLines, + addedLines, + deletedLines, + }; + } +} + +function uriPathToDiskPath(path: string): string { + // If Windows, convert URI path to disk path + if (process.platform === "win32") { + return path.replace(/^\//, ""); + } + // For other platforms, return the path as is + return path; +} diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts new file mode 100644 index 0000000..d25edb3 --- /dev/null +++ b/src/utils/pathUtils.ts @@ -0,0 +1,51 @@ +import * as vscode from "vscode"; +import { OIL_PREVIEW_SCHEME, OIL_SCHEME } from "../constants"; + +export function normalizePathToUri(path: string | undefined = ""): string { + if (path.startsWith("/")) { + return path; + } + // Normalize the path to a URI format + const normalizedPath = path.replace(/\\/g, "/"); + return `/${normalizedPath}`; +} + +export function uriPathToDiskPath(path: string): string { + // If Windows, convert URI path to disk path + if (process.platform === "win32") { + return path.replace(/^\//, ""); + } + // For other platforms, return the path as is + return path; +} + +export function removeTrailingSlash(path: string): string { + return path.endsWith("/") ? path.slice(0, -1) : path; +} + +export function updateOilUri( + oilState: { tempFileUri: vscode.Uri }, + newPath: string +): vscode.Uri { + const normalizedPath = removeTrailingSlash(normalizePathToUri(newPath)); + const newUri = vscode.Uri.parse(`${OIL_SCHEME}://oil${normalizedPath}`); + + // Update the state with the new URI + oilState.tempFileUri = newUri; + + return newUri; +} + +export function formatPath(path: string): string { + return vscode.workspace.asRelativePath(path); +} + +export function oilUriToDiskPath(uri: vscode.Uri): string { + // Convert an Oil URI to a file system path + if (!(uri.scheme === OIL_SCHEME || uri.scheme === OIL_PREVIEW_SCHEME)) { + throw new Error("Invalid Oil URI"); + } + return `${uri.path + .replace(`${OIL_SCHEME}://oil`, "") + .replace(`${OIL_PREVIEW_SCHEME}://oil-preview`, "")}/`; +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts new file mode 100644 index 0000000..d39a918 --- /dev/null +++ b/src/utils/settings.ts @@ -0,0 +1,34 @@ +import * as vscode from "vscode"; + +export function getDisableVimKeymapsSetting(): boolean { + const config = vscode.workspace.getConfiguration("oil-code"); + return config.get("disableVimKeymaps") || false; +} + +export function getDisableOpenCwdNothingOpenSetting(): boolean { + const config = vscode.workspace.getConfiguration("oil-code"); + return config.get("disableOpenCwdNothingOpen") || false; +} + +let restoreAutoSave = false; + +export async function checkAndDisableAutoSave() { + const config = vscode.workspace.getConfiguration("files"); + const autoSave = config.get("autoSave"); + if (autoSave === "afterDelay") { + restoreAutoSave = true; + await config.update("autoSave", "off", vscode.ConfigurationTarget.Global); + } +} + +export async function checkAndEnableAutoSave() { + const config = vscode.workspace.getConfiguration("files"); + if (restoreAutoSave) { + await config.update( + "autoSave", + "afterDelay", + vscode.ConfigurationTarget.Global + ); + restoreAutoSave = false; + } +} diff --git a/src/vim/neovim.ts b/src/vim/neovim.ts new file mode 100644 index 0000000..20232c4 --- /dev/null +++ b/src/vim/neovim.ts @@ -0,0 +1,45 @@ +import * as vscode from "vscode"; +import { getDisableVimKeymapsSetting } from "../utils/settings"; +import oilCodeLua from "./oil.code.lua"; +import { logger } from "../logger"; + +export async function isNeovimAvailable(): Promise { + try { + // Try to execute a simple command provided by the Neovim extension + await vscode.commands.executeCommand("vscode-neovim.lua", "return 1"); + logger.info("Neovim extension is available"); + return true; + } catch (error) { + // If command execution fails, the extension is likely not available + logger.info("Neovim extension not available or command failed"); + return false; + } +} + +export async function registerNeovimKeymap(): Promise { + // Check if vim keymaps are disabled in settings + const isDisabled = getDisableVimKeymapsSetting(); + if (isDisabled) { + logger.info("Vim keymaps are disabled in settings."); + return false; + } + + // Check if the extension is available before attempting to register keymaps + if (await isNeovimAvailable()) { + try { + logger.info("Registering Neovim keymaps"); + + // Use the Neovim extension's command API to register Lua code + await vscode.commands.executeCommand("vscode-neovim.lua", oilCodeLua); + + logger.info("Neovim keymaps registered successfully"); + return true; + } catch (error) { + logger.error("Failed to register Neovim keymap:", error); + return false; + } + } + + logger.info("Neovim extension not available, skipping keymap registration"); + return false; +} diff --git a/src/oil.code.lua b/src/vim/oil.code.lua similarity index 100% rename from src/oil.code.lua rename to src/vim/oil.code.lua diff --git a/src/vim/vimKeymaps.ts b/src/vim/vimKeymaps.ts new file mode 100644 index 0000000..d8a8991 --- /dev/null +++ b/src/vim/vimKeymaps.ts @@ -0,0 +1,47 @@ +import { + MAX_EXTENSION_DETECTION_RETRIES, + EXTENSION_DETECTION_DELAY, +} from "../constants"; +import { registerNeovimKeymap } from "./neovim"; +import { registerVSCodeVimKeymap } from "./vscodeVim"; +import { logger } from "../logger"; + +export async function attemptRegisteringVimKeymaps( + retries: number = MAX_EXTENSION_DETECTION_RETRIES, + delay: number = EXTENSION_DETECTION_DELAY +): Promise { + let neovimRegistered = false; + let vscodevimRegistered = false; + + for (let attempt = 0; attempt <= retries; attempt++) { + if (attempt > 0) { + logger.info( + `Retry attempt ${attempt} of ${retries} to register Vim keymaps` + ); + // Wait before retry + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + // Try to register Neovim keymaps if not already registered + if (!neovimRegistered) { + neovimRegistered = await registerNeovimKeymap(); + } + + // Try to register VSCodeVim keymaps if not already registered + if (!vscodevimRegistered) { + vscodevimRegistered = await registerVSCodeVimKeymap(); + } + + // If both are registered or we've exhausted attempts, we're done + if (neovimRegistered || vscodevimRegistered) { + logger.info( + "Successfully registered keymaps for all available Vim extensions" + ); + break; + } + } + + if (!neovimRegistered && !vscodevimRegistered) { + logger.info("No Vim extensions were detected after all retry attempts"); + } +} diff --git a/src/vim/vscodeVim.ts b/src/vim/vscodeVim.ts new file mode 100644 index 0000000..67406b9 --- /dev/null +++ b/src/vim/vscodeVim.ts @@ -0,0 +1,130 @@ +import * as vscode from "vscode"; +import { getDisableVimKeymapsSetting } from "../utils/settings"; +import { logger } from "../logger"; + +export function isVSCodeVimAvailable(): Promise { + return new Promise((resolve) => { + try { + // VSCodeVim extension adds a "vim" configuration section + const vimConfig = vscode.workspace.getConfiguration("vim"); + + // Check if a known setting exists to confirm the extension is activated + if (vimConfig && vimConfig.has("normalModeKeyBindings")) { + logger.info("VSCodeVim extension is available"); + resolve(true); + } else { + logger.info( + "VSCodeVim extension not available or not fully initialized" + ); + resolve(false); + } + } catch (error) { + logger.error("Error checking VSCodeVim availability:", error); + resolve(false); + } + }); +} + +export async function registerVSCodeVimKeymap(): Promise { + // Check if vim keymaps are disabled in settings + const isDisabled = getDisableVimKeymapsSetting(); + if (isDisabled) { + logger.info("Vim keymaps are disabled in settings."); + return false; + } + + // Check if the extension is available before attempting to register keymaps + if (await isVSCodeVimAvailable()) { + try { + logger.info("Registering VSCodeVim keymaps"); + + // Configure VSCodeVim using workspace configuration + const vimConfig = vscode.workspace.getConfiguration("vim"); + const normalModeKeymap = + vimConfig.get("normalModeKeyBindings") || []; + let updatedKeymap = [...normalModeKeymap]; // Make a copy + let keymapChanged = false; + + // Check for and add the Oil open binding if not present + const hasOilOpenBinding = normalModeKeymap.some((binding) => + binding.commands?.some( + (cmd: { command: string }) => cmd.command === "oil-code.open" + ) + ); + + if (!hasOilOpenBinding) { + updatedKeymap.push({ + before: ["-"], + commands: [{ command: "oil-code.open" }], + }); + keymapChanged = true; + } + + // Check for and add the Oil select binding if not present + const hasOilSelectBinding = normalModeKeymap.some((binding) => + binding.commands?.some( + (cmd: { command: string }) => cmd.command === "oil-code.select" + ) + ); + + if (!hasOilSelectBinding) { + updatedKeymap.push({ + before: [""], + commands: [{ command: "oil-code.select" }], + }); + keymapChanged = true; + } + + // Check for and add the Oil refresh binding if not present + const hasOilRefreshBinding = normalModeKeymap.some((binding) => + binding.commands?.some( + (cmd: { command: string }) => cmd.command === "oil-code.refresh" + ) + ); + + if (!hasOilRefreshBinding) { + updatedKeymap.push({ + before: [""], + commands: [{ command: "oil-code.refresh" }], + }); + keymapChanged = true; + } + + // Check for and add the Oil cd binding if not present + const hasOilCdBinding = normalModeKeymap.some((binding) => + binding.commands?.some( + (cmd: { command: string }) => cmd.command === "oil-code.cd" + ) + ); + if (!hasOilCdBinding) { + updatedKeymap.push({ + before: ["`"], + commands: [{ command: "oil-code.cd" }], + }); + keymapChanged = true; + } + + // Update the configuration if changes were made + if (keymapChanged) { + await vimConfig.update( + "normalModeKeyBindings", + updatedKeymap, + vscode.ConfigurationTarget.Global + ); + logger.info("VSCodeVim keymaps updated successfully"); + } else { + logger.info("VSCodeVim keymaps already configured"); + } + + return true; + } catch (error) { + logger.error("Failed to register VSCodeVim keymap:", error); + return false; + } + } + + logger.info( + "VSCodeVim extension not available, skipping keymap registration" + ); + return false; +}