diff --git a/README.md b/README.md
index c0b1f59..1ff9d14 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ To open oil.code:
| `ctrl+p` | `alt+p` | `oil-code.preview` | Toggle preview window of entry under the cursor |
| `ctrl+l` | `alt+l` | `oil-code.refresh` | Refresh directory listing from disk |
| \` | alt+\` | `oil-code.cd` | Change Directory to current |
+| `gd` | `alt+shift+d` | `oil-code.toggleDetails` | Toggle detail columns visibility |
| -- No Default -- | `alt+shift+h` | `oil-code.help` | Display oil.code default keymaps |
### [vscode-neovim](https://github.com/vscode-neovim/vscode-neovim) Keymaps
@@ -67,6 +68,7 @@ if vim.g.vscode then
map("n", "", function() vscode.action('oil-code.selectTab') end)
map("n", "", function() vscode.action('oil-code.refresh') end)
map("n", "`", function() vscode.action('oil-code.cd') end)
+ map("n", "gd", function() vscode.action('oil-code.toggleDetails') end)
end,
})
end
@@ -96,6 +98,14 @@ If you're using VSCodeVim and want to customize the keymaps for oil.code:
"command": "oil-code.select"
}
]
+ },
+ {
+ "before": ["g", "d"],
+ "commands": [
+ {
+ "command": "oil-code.toggleDetails"
+ }
+ ]
}
]
```
@@ -170,6 +180,23 @@ You can set the VSCode text editor to use an installed Nerd Font by setting `"ed
Once you have a Nerd Font set for your editor font, to use these icons in your oil view, set `"oil-code.hasNerdFont": true`.
+## Detail Columns
+
+By default, oil.code shows file icons only. You can add metadata columns to the directory listing with `"oil-code.columns"`:
+
+```json
+"oil-code.columns": ["icon", "permissions", "size", "mtime"]
+```
+
+Supported columns:
+
+- `icon` — file or directory icon
+- `permissions` — Unix-style permissions such as `-rw-r--r--`
+- `size` — human-readable file size
+- `mtime` — last modified time
+
+The metadata is rendered as non-editable decoration text, so the oil buffer keeps the same `/NNN filename` format for file operations. Use `gd` in Vim mode or `alt+shift+d` to toggle detail column visibility for the current session.
+
## Confirmation Dialog
By default, oil.code uses a modal confirmation dialog when you save file operations. You can enable an alternate confirmation interface by setting `"oil-code.enableAlternateConfirmation": true`.
diff --git a/package.json b/package.json
index 0983372..c6d4c96 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,10 @@
"command": "oil-code.cd",
"title": "Oil: cd - Change directory to the currently opened directory",
"when": "editorTextFocus && editorLangId == oil"
+ },
+ {
+ "command": "oil-code.toggleDetails",
+ "title": "Oil: Toggle Detail Columns"
}
],
"languages": [
@@ -129,6 +133,11 @@
"command": "oil-code.cd",
"key": "alt+`",
"when": "editorTextFocus && editorLangId == oil"
+ },
+ {
+ "command": "oil-code.toggleDetails",
+ "key": "alt+shift+d",
+ "when": "editorTextFocus && editorLangId == oil"
}
],
"grammars": [
@@ -175,6 +184,21 @@
"type": "boolean",
"default": false,
"description": "Enable preview automatically when opening oil. Default is false."
+ },
+ "oil-code.columns": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["icon", "permissions", "size", "mtime"],
+ "enumDescriptions": [
+ "File type icon",
+ "Unix-style file permissions (e.g. -rw-r--r--)",
+ "File size in human-readable form (e.g. 12K)",
+ "Last modified time (e.g. Mar 14 14:23)"
+ ]
+ },
+ "default": ["icon"],
+ "description": "Columns to display in oil directory listings. 'icon' controls the file type icon. Reload the oil buffer after changing this setting."
}
}
}
diff --git a/src/commands/help.ts b/src/commands/help.ts
index 1f282cc..bb97646 100644
--- a/src/commands/help.ts
+++ b/src/commands/help.ts
@@ -28,6 +28,7 @@ export async function help() {
"alt+`",
"Change VSCode working directory to current oil directory",
],
+ ["toggleDetails", "gd", "alt+shift+d", "Toggle detail columns visibility"],
];
// Display the message in a Markdown preview panel
diff --git a/src/commands/refresh.ts b/src/commands/refresh.ts
index b66ebd4..bf4efa7 100644
--- a/src/commands/refresh.ts
+++ b/src/commands/refresh.ts
@@ -52,6 +52,7 @@ export async function refresh() {
try {
// Clear the visited path cache for the current directory to force refresh from disk
oilState.visitedPaths.delete(currentPath);
+ oilState.metadataCache.delete(currentPath);
// Get updated directory content from disk
const directoryContent = await getDirectoryListing(
diff --git a/src/commands/toggleDetails.ts b/src/commands/toggleDetails.ts
new file mode 100644
index 0000000..c8cfb91
--- /dev/null
+++ b/src/commands/toggleDetails.ts
@@ -0,0 +1,14 @@
+import * as vscode from "vscode";
+import { toggleDetailsVisible } from "../state/columnState";
+import { updateDecorations } from "../decorations";
+
+export function toggleDetails(): void {
+ toggleDetailsVisible();
+
+ // Redraw decorations on all visible oil editors
+ for (const editor of vscode.window.visibleTextEditors) {
+ if (editor.document.languageId === "oil") {
+ updateDecorations(editor);
+ }
+ }
+}
diff --git a/src/constants.ts b/src/constants.ts
index 3001984..dc2f7fb 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,5 +1,13 @@
import * as vscode from "vscode";
+export type MetadataColumn = "icon" | "permissions" | "size" | "mtime";
+
+export interface FileMetadata {
+ permissions: string;
+ size: string;
+ mtime: string;
+}
+
export interface OilEntry {
identifier: string;
value: string;
@@ -13,6 +21,7 @@ export interface OilState {
identifierCounter: number;
visitedPaths: Map;
editedPaths: Map;
+ metadataCache: Map>;
openAfterSave?: string;
}
diff --git a/src/decorations.ts b/src/decorations.ts
index d2b229e..e8a915e 100644
--- a/src/decorations.ts
+++ b/src/decorations.ts
@@ -1,6 +1,11 @@
import * as vscode from "vscode";
import * as path from "path";
import { getNerdFontFileIcon } from "./nerd-fonts";
+import { peekOilState } from "./state/oilState";
+import { getDetailsVisible } from "./state/columnState";
+import { getColumnsSettings } from "./utils/settings";
+import { formatMetadataColumns } from "./utils/metadataUtils";
+import { oilUriToDiskPath, normalizePathToUri, removeTrailingSlash } from "./utils/pathUtils";
// Create decoration type for hidden prefix
const hiddenPrefixDecoration = vscode.window.createTextEditorDecorationType({
@@ -108,8 +113,30 @@ function getFileIcon(fileName: string, isDirectory: boolean): string {
}
}
-// Decoration types for different file types (created on demand)
-const fileIconDecorations = new Map();
+export function buildEntryDecorationContent({
+ icon,
+ metadataText,
+}: {
+ icon: string;
+ metadataText?: string;
+}): string {
+ if (metadataText) {
+ return `${icon.trimEnd()}${metadataText}`;
+ }
+
+ return /\s$/.test(icon) ? icon : `${icon} `;
+}
+
+// Single decoration type for all entry before-text. The icon and metadata columns
+// are emitted together so VS Code cannot reorder multiple same-position
+// decorations while navigating between directories.
+let entryDecorationType: vscode.TextEditorDecorationType | null = null;
+function getEntryDecorationType(): vscode.TextEditorDecorationType {
+ if (!entryDecorationType) {
+ entryDecorationType = vscode.window.createTextEditorDecorationType({});
+ }
+ return entryDecorationType;
+}
// Apply decorations to hide prefixes
export function updateDecorations(editor: vscode.TextEditor | undefined) {
@@ -120,15 +147,33 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) {
const document = editor.document;
const hiddenRanges: vscode.Range[] = [];
- // Clear previous icon decorations
- fileIconDecorations.forEach((decoration) => {
- editor.setDecorations(decoration, []);
- });
+ // Clear previous entry decorations
+ if (entryDecorationType) {
+ editor.setDecorations(entryDecorationType, []);
+ }
- // Track icon decorations for this update
- const iconDecorations = new Map();
+ // Determine if metadata columns should be rendered
+ const oilState = peekOilState();
+ const columns = getColumnsSettings();
+ const detailsVisible = getDetailsVisible();
+ const metaColumns = columns.filter((c) => c !== "icon");
+ const showMetadata = oilState !== undefined && detailsVisible && metaColumns.length > 0;
+
+ // Resolve the metadata map for the current directory (once, before the line loop)
+ let metadataMap: Map | undefined;
+ if (showMetadata) {
+ try {
+ const diskPath = oilUriToDiskPath(document.uri);
+ const folderPathUri = removeTrailingSlash(normalizePathToUri(diskPath));
+ metadataMap = oilState!.metadataCache.get(folderPathUri);
+ } catch {
+ // Not a valid oil URI — skip metadata rendering
+ }
+ }
+
+ const entryDecorations: vscode.DecorationOptions[] = [];
- // Add icon after the prefix and space
+ // Add icon and optional metadata after the hidden prefix
// Get appropriate icon based on configuration
const config = vscode.workspace.getConfiguration("oil-code");
const hasNerdFont = config.get("hasNerdFont") === true;
@@ -150,51 +195,40 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) {
const endPos = new vscode.Position(i, prefixLength);
hiddenRanges.push(new vscode.Range(startPos, endPos));
- let icon;
- let fontColor = "inherit";
- let iconKey = "";
+ let icon: string;
+ let fontColor: string | vscode.ThemeColor | undefined;
if (hasNerdFont) {
- const {
- icon: nerdIcon,
- color,
- extension,
- } = getNerdFontFileIcon(fileName, isDirectory);
+ const { icon: nerdIcon, color } = getNerdFontFileIcon(fileName, isDirectory);
icon = nerdIcon;
fontColor = color;
- iconKey = extension;
} else {
icon = getFileIcon(fileName, isDirectory);
- iconKey = isDirectory ? "directory" : path.extname(fileName) || "file";
- }
-
- // Create decoration type if it doesn't exist
- if (!fileIconDecorations.has(iconKey)) {
- fileIconDecorations.set(
- iconKey,
- vscode.window.createTextEditorDecorationType({
- before: {
- contentText: icon,
- width: "1.5em",
- color: fontColor,
- },
- })
- );
}
- // Get or create the ranges array for this icon type
- if (!iconDecorations.has(iconKey)) {
- iconDecorations.set(iconKey, []);
+ let metadataText = "";
+ if (showMetadata && metadataMap) {
+ const meta = metadataMap.get(fileName);
+ if (meta) {
+ metadataText = formatMetadataColumns(meta, metaColumns);
+ if (metadataText) {
+ // A combined icon+metadata decoration must use one color. Prefer the
+ // metadata/inlay hint color when details are visible so the data
+ // columns read as subdued metadata instead of file-type glyphs.
+ fontColor = new vscode.ThemeColor("editorInlayHint.foreground");
+ }
+ }
}
- // Add decoration range at the beginning of visible part
- iconDecorations
- .get(iconKey)!
- .push(
- new vscode.Range(
- new vscode.Position(i, prefixLength),
- new vscode.Position(i, prefixLength)
- )
- );
+ const filenameStartPos = new vscode.Position(i, prefixLength);
+ entryDecorations.push({
+ range: new vscode.Range(filenameStartPos, filenameStartPos),
+ renderOptions: {
+ before: {
+ contentText: buildEntryDecorationContent({ icon, metadataText }),
+ color: fontColor,
+ },
+ },
+ });
// If cursor is within the prefix area, move it to the first visible character
for (let selection of editor.selections) {
@@ -219,13 +253,9 @@ export function updateDecorations(editor: vscode.TextEditor | undefined) {
// Apply the hidden prefix decoration
editor.setDecorations(hiddenPrefixDecoration, hiddenRanges);
- // Apply file type icon decorations
- for (const [iconKey, ranges] of iconDecorations.entries()) {
- const decoration = fileIconDecorations.get(iconKey);
- if (decoration) {
- editor.setDecorations(decoration, ranges);
- }
- }
+ // Apply entry before-text decorations after hiding prefixes. Icon and metadata
+ // share one per-line decoration to keep VS Code from reordering columns.
+ editor.setDecorations(getEntryDecorationType(), entryDecorations);
}
// Disposable for cleanup
@@ -343,8 +373,10 @@ export function activateDecorations(context: vscode.ExtensionContext) {
context.subscriptions.push(decorationUpdateListener, {
dispose: () => {
hiddenPrefixDecoration.dispose();
- fileIconDecorations.forEach((decoration) => decoration.dispose());
- fileIconDecorations.clear();
+ if (entryDecorationType) {
+ entryDecorationType.dispose();
+ entryDecorationType = null;
+ }
if (decorationUpdateListener) {
decorationUpdateListener.dispose();
diff --git a/src/extension.ts b/src/extension.ts
index 8869552..5d6a8dd 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -24,6 +24,7 @@ import { openCwd } from "./commands/openCwd";
import { preview } from "./commands/preview";
import { refresh } from "./commands/refresh";
import { cd } from "./commands/cd";
+import { toggleDetails } from "./commands/toggleDetails";
import { logger } from "./logger";
// In your extension's activate function
@@ -79,7 +80,8 @@ 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", cd)
+ vscode.commands.registerCommand("oil-code.cd", cd),
+ vscode.commands.registerCommand("oil-code.toggleDetails", toggleDetails)
);
// Make initial attempt to register Vim keymaps with retries
diff --git a/src/state/columnState.ts b/src/state/columnState.ts
new file mode 100644
index 0000000..53a7f3a
--- /dev/null
+++ b/src/state/columnState.ts
@@ -0,0 +1,13 @@
+let _detailsVisible = true;
+
+export function getDetailsVisible(): boolean {
+ return _detailsVisible;
+}
+
+export function setDetailsVisible(v: boolean): void {
+ _detailsVisible = v;
+}
+
+export function toggleDetailsVisible(): void {
+ _detailsVisible = !_detailsVisible;
+}
diff --git a/src/state/initState.ts b/src/state/initState.ts
index 5fdc8bf..2411dd1 100644
--- a/src/state/initState.ts
+++ b/src/state/initState.ts
@@ -24,6 +24,7 @@ export function initOilState(): OilState {
identifierCounter: 1,
visitedPaths: new Map(),
editedPaths: new Map(),
+ metadataCache: new Map(),
};
return newState;
@@ -39,6 +40,7 @@ export function initOilStateWithPath(path: string): OilState {
identifierCounter: 1,
visitedPaths: new Map(),
editedPaths: new Map(),
+ metadataCache: new Map(),
};
return newState;
diff --git a/src/state/oilState.ts b/src/state/oilState.ts
index e9c4ac7..1fe0efc 100644
--- a/src/state/oilState.ts
+++ b/src/state/oilState.ts
@@ -18,6 +18,10 @@ export function setOilState(state: OilState | undefined) {
oilState = state;
}
+export function peekOilState(): OilState | undefined {
+ return oilState;
+}
+
export function getCurrentPath(): string | undefined {
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && oilState) {
diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts
index 5052c84..7cb05ef 100644
--- a/src/test/extension.test.ts
+++ b/src/test/extension.test.ts
@@ -9,6 +9,8 @@ import { sleep } from "./utils/sleep";
import { assertProjectFileStructure } from "./utils/assertProjectFileStructure";
import { moveCursorToLine } from "./utils/moveCursorToLine";
import { assertSelectionOnLine } from "./utils/assertSelectionOnLine";
+import { buildEntryDecorationContent } from "../decorations";
+import { formatMetadataColumns, formatSize } from "../utils/metadataUtils";
async function cleanupTestDir() {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
@@ -1124,4 +1126,125 @@ suite("oil.code", () => {
"Content of moved file does not match expected content"
);
});
+
+ test("toggleDetails command executes without error", async () => {
+ // Run the command twice (toggle on, toggle off) to verify it
+ // completes without throwing in both directions.
+ // Direct state inspection is not possible here because the extension
+ // is bundled by esbuild, giving it a separate module instance from
+ // the test's compiled output.
+ await vscode.commands.executeCommand("oil-code.toggleDetails");
+ await vscode.commands.executeCommand("oil-code.toggleDetails");
+ });
+
+ test("Rename with metadata columns enabled does not corrupt file operations", async () => {
+ // Open oil in icon+size+mtime mode
+ await vscode.workspace
+ .getConfiguration("oil-code")
+ .update("columns", ["icon", "size", "mtime"], vscode.ConfigurationTarget.Global);
+
+ await vscode.commands.executeCommand("oil-code.open");
+ await waitForDocumentText("/000 ../");
+
+ const editor = vscode.window.activeTextEditor;
+ assert.ok(editor, "No active editor");
+
+ // Create a file
+ await editor.edit((editBuilder) => {
+ editBuilder.insert(new vscode.Position(1, 0), `${newline}metadata-rename-test.md`);
+ });
+ await saveFile();
+ await waitForDocumentText(["/000 ../", "/001 metadata-rename-test.md"]);
+
+ // Rename the file by editing only the filename portion (after the identifier)
+ const line = editor.document.lineAt(1).text; // "/001 metadata-rename-test.md"
+ const prefixLength = 5; // "/001 "
+ await editor.edit((editBuilder) => {
+ editBuilder.replace(
+ new vscode.Range(
+ new vscode.Position(1, prefixLength),
+ new vscode.Position(1, line.length)
+ ),
+ "metadata-renamed.md"
+ );
+ });
+ await saveFile();
+
+ await assertProjectFileStructure(["metadata-renamed.md"]);
+
+ // Restore columns setting
+ await vscode.workspace
+ .getConfiguration("oil-code")
+ .update("columns", ["icon"], vscode.ConfigurationTarget.Global);
+ });
+
+ test("When columns is icon-only, buffer text stays plain /NNN filename format", async () => {
+ // Verifies that metadata is never embedded in buffer text (always decoration-only).
+ // Cross-bundle internal state (metadataCache) cannot be read from test code because
+ // the extension is bundled by esbuild into a separate module instance.
+ await vscode.workspace
+ .getConfiguration("oil-code")
+ .update("columns", ["icon"], vscode.ConfigurationTarget.Global);
+
+ await vscode.commands.executeCommand("oil-code.open");
+ await waitForDocumentText("/000 ../");
+
+ const editor = vscode.window.activeTextEditor;
+ assert.ok(editor, "Oil editor should be active");
+
+ const text = editor.document.getText();
+ const lines = text.split(newline).filter((l) => l.trim().length > 0);
+ for (const line of lines) {
+ assert.match(
+ line,
+ /^\/\d{3} /,
+ `Each line must start with /NNN prefix only — no metadata in buffer text. Got: ${line}`
+ );
+ }
+ });
+
+ test("metadata size column stays fixed-width around unit boundaries", () => {
+ const sizes = [
+ 0,
+ 999,
+ 1023,
+ 1024,
+ 1024 * 999,
+ 1024 * 1024 - 1,
+ 1024 * 1024,
+ 1024 * 1024 * 999,
+ ];
+
+ for (const size of sizes) {
+ assert.ok(
+ formatSize(size).length <= 4,
+ `Expected ${size} bytes to fit the 4-character size column, got ${formatSize(size)}`
+ );
+ }
+ });
+
+ test("metadata formatter pads columns consistently with non-breaking spaces", () => {
+ const formatted = formatMetadataColumns(
+ {
+ permissions: "-rw-r--r--",
+ size: "1K",
+ mtime: "Jan\u00A005\u00A014:23",
+ },
+ ["permissions", "size", "mtime"]
+ );
+
+ assert.strictEqual(
+ formatted,
+ "\u00A0\u00A0-rw-r--r--\u00A0\u00A0\u00A0\u00A01K\u00A0\u00A0Jan\u00A005\u00A014:23\u00A0\u00A0"
+ );
+ });
+
+ test("decorated listing columns are emitted as one stable before-text string", () => {
+ const content = buildEntryDecorationContent({
+ icon: "📄",
+ metadataText: "\u00A0\u00A0999B\u00A0\u00A0",
+ });
+
+ assert.strictEqual(content, "📄\u00A0\u00A0999B\u00A0\u00A0");
+ });
});
diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts
index 59a3576..3f8cbe5 100644
--- a/src/utils/fileUtils.ts
+++ b/src/utils/fileUtils.ts
@@ -4,6 +4,8 @@ import * as path from "path";
import { GO_UP_IDENTIFIER, OilState } from "../constants";
import { removeTrailingSlash, normalizePathToUri } from "./pathUtils";
import { newline } from "../newline";
+import { populateMetadataCache } from "./metadataUtils";
+import { getColumnsSettings } from "./settings";
export async function getDirectoryListing(
folderPath: string,
@@ -79,6 +81,11 @@ export async function getDirectoryListing(
oilState.visitedPaths.set(folderPathUri, listingsWithIds);
+ const activeColumns = getColumnsSettings();
+ if (activeColumns.some((c) => c !== "icon")) {
+ populateMetadataCache(folderPath, listings, oilState);
+ }
+
return listingsWithIds.join(newline);
}
diff --git a/src/utils/metadataUtils.ts b/src/utils/metadataUtils.ts
new file mode 100644
index 0000000..0cce26a
--- /dev/null
+++ b/src/utils/metadataUtils.ts
@@ -0,0 +1,118 @@
+import * as fs from "fs";
+import * as path from "path";
+import { FileMetadata, MetadataColumn, OilState } from "../constants";
+import { normalizePathToUri, removeTrailingSlash } from "./pathUtils";
+
+const MONTHS = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+];
+
+// Returns at most 5 chars (e.g. "999B", " 1.0K", "99.9M") so padStart(5) always gives
+// a fixed-width right-aligned string.
+export function formatSize(bytes: number): string {
+ if (bytes < 1000) {
+ return `${bytes}B`; // max "999B" = 4 chars
+ }
+ if (bytes < 1000 * 1024) {
+ const k = bytes / 1024;
+ return k >= 10 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`; // max "999K" = 4 chars
+ }
+ if (bytes < 1000 * 1024 * 1024) {
+ const m = bytes / (1024 * 1024);
+ return m >= 10 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`; // max "999M" = 4 chars
+ }
+ const g = bytes / (1024 * 1024 * 1024);
+ return g >= 10 ? `${Math.round(g)}G` : `${g.toFixed(1)}G`; // max "999G" = 4 chars
+}
+
+export function formatMtime(mtime: Date): string {
+ const month = MONTHS[mtime.getMonth()];
+ const day = String(mtime.getDate()).padStart(2, "0");
+ const now = new Date();
+ if (mtime.getFullYear() === now.getFullYear()) {
+ const hours = String(mtime.getHours()).padStart(2, "0");
+ const minutes = String(mtime.getMinutes()).padStart(2, "0");
+ return `${month}\u00A0${day}\u00A0${hours}:${minutes}`;
+ }
+ return `${month}\u00A0${day}\u00A0\u00A0${mtime.getFullYear()}`;
+}
+
+export function formatPermissions(stat: fs.Stats): string {
+ const typeChar = stat.isDirectory() ? "d" : stat.isSymbolicLink() ? "l" : "-";
+
+ if (process.platform === "win32") {
+ const readonly = !(stat.mode & 0o200);
+ return readonly ? `${typeChar}r--r--r--` : `${typeChar}rw-rw-rw-`;
+ }
+
+ const mode = stat.mode & 0o777;
+ const rwx = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
+ return `${typeChar}${rwx[(mode >> 6) & 7]}${rwx[(mode >> 3) & 7]}${rwx[mode & 7]}`;
+}
+
+export function getFileMetadata(filePath: string, stat: fs.Stats): FileMetadata {
+ return {
+ permissions: formatPermissions(stat),
+ size: formatSize(stat.size),
+ mtime: formatMtime(stat.mtime),
+ };
+}
+
+// Column widths (monospace chars), all padding uses non-breaking spaces (\u00A0):
+// permissions : exactly 10 (e.g. "-rw-r--r--")
+// size : right-aligned in 4, padded left with NBSP (e.g. "\u00A012K", "999B")
+// mtime : exactly 12 (e.g. "Mar\u00A014\u00A014:23", "Mar\u00A014\u00A0\u00A02024")
+// Separator between columns: 2 NBSP. Leading 2 NBSP (gap from icon), trailing 2 NBSP before filename.
+// All rows in the same directory with the same column set are identical width.
+export function formatMetadataColumns(
+ meta: FileMetadata,
+ columns: MetadataColumn[]
+): string {
+ const parts: string[] = [];
+ for (const col of columns) {
+ switch (col) {
+ case "permissions":
+ parts.push(meta.permissions); // always 10 chars
+ break;
+ case "size":
+ parts.push(meta.size.padStart(4, "\u00A0")); // right-aligned, always 4 chars, NBSP padding
+ break;
+ case "mtime":
+ parts.push(meta.mtime); // always 12 chars
+ break;
+ // "icon" is not a metadata column — silently ignored
+ }
+ }
+ // Leading 2 NBSP (gap from icon), 2 NBSP between columns, 2 NBSP before filename
+ return parts.length > 0 ? "\u00A0\u00A0" + parts.join("\u00A0\u00A0") + "\u00A0\u00A0" : "";
+}
+
+export function populateMetadataCache(
+ folderPath: string,
+ listings: string[],
+ oilState: OilState
+): void {
+ const folderPathUri = removeTrailingSlash(normalizePathToUri(folderPath));
+ const fileMap = new Map();
+
+ for (const name of listings) {
+ if (name === "../") {
+ fileMap.set(name, {
+ permissions: "-".padStart(10, "\u00A0"),
+ size: "-",
+ mtime: "-".padStart(12, "\u00A0"),
+ });
+ continue;
+ }
+ try {
+ const fullPath = path.join(folderPath, name.replace(/\/$/, ""));
+ const stat = fs.statSync(fullPath);
+ fileMap.set(name, getFileMetadata(fullPath, stat));
+ } catch {
+ // Skip entries we can't stat (broken symlinks, permission errors, etc.)
+ }
+ }
+
+ oilState.metadataCache.set(folderPathUri, fileMap);
+}
diff --git a/src/utils/settings.ts b/src/utils/settings.ts
index c915dae..1ef4951 100644
--- a/src/utils/settings.ts
+++ b/src/utils/settings.ts
@@ -1,4 +1,5 @@
import * as vscode from "vscode";
+import { MetadataColumn } from "../constants";
export function getDisableVimKeymapsSetting(): boolean {
const config = vscode.workspace.getConfiguration("oil-code");
@@ -30,6 +31,11 @@ export function getPreviewByDefaultSetting(): boolean {
return config.get("previewByDefault") || false;
}
+export function getColumnsSettings(): MetadataColumn[] {
+ const config = vscode.workspace.getConfiguration("oil-code");
+ return config.get("columns") ?? ["icon"];
+}
+
let restoreAutoSave = false;
export async function checkAndDisableAutoSave() {
diff --git a/src/vim/oil.code.lua b/src/vim/oil.code.lua
index 18b2ea8..e6728a6 100644
--- a/src/vim/oil.code.lua
+++ b/src/vim/oil.code.lua
@@ -22,5 +22,6 @@ vim.api.nvim_create_autocmd({'FileType'}, {
map("n", "", function() vscode.action('oil-code.selectTab') end)
map("n", "", function() vscode.action('oil-code.refresh') end)
map("n", "`", function() vscode.action('oil-code.cd') end)
+ map("n", "gd", function() vscode.action('oil-code.toggleDetails') end)
end,
})
diff --git a/src/vim/vscodeVim.ts b/src/vim/vscodeVim.ts
index 67406b9..4851dec 100644
--- a/src/vim/vscodeVim.ts
+++ b/src/vim/vscodeVim.ts
@@ -104,6 +104,20 @@ export async function registerVSCodeVimKeymap(): Promise {
keymapChanged = true;
}
+ // Check for and add the Oil toggleDetails binding if not present
+ const hasOilToggleDetailsBinding = normalModeKeymap.some((binding) =>
+ binding.commands?.some(
+ (cmd: { command: string }) => cmd.command === "oil-code.toggleDetails"
+ )
+ );
+ if (!hasOilToggleDetailsBinding) {
+ updatedKeymap.push({
+ before: ["g", "d"],
+ commands: [{ command: "oil-code.toggleDetails" }],
+ });
+ keymapChanged = true;
+ }
+
// Update the configuration if changes were made
if (keymapChanged) {
await vimConfig.update(