feat: add Explorer Stepper plugin

Add natural sibling-file navigation, localized commands and notices, configurable hotkey documentation, and regression tests.
This commit is contained in:
kefate 2026-06-15 17:44:04 +08:00
parent d859a6dc11
commit 1e62778cd4
15 changed files with 1653 additions and 0 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
[*.json]
indent_style = space
indent_size = 2

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
main.js
*.map
data.json
.DS_Store

84
README.md Normal file
View file

@ -0,0 +1,84 @@
# Explorer Stepper
Explorer Stepper is a lightweight Obsidian plugin for navigating to the
previous or next file in the current folder.
## Features
- Navigate to the previous or next file with configurable hotkeys.
- Include every direct child file, including Markdown, images, PDFs, and
Canvas files.
- Sort file names naturally, so `file2` appears before `file10`.
- Stay in the current folder without scanning nested folders.
- Stop at the first and last file instead of wrapping around.
- Display commands and notices in English or Chinese based on the Obsidian
interface language.
## How It Works
Explorer Stepper reads the direct files in the active file's folder and sorts
them by file name using locale-aware natural ordering. It then opens the
adjacent file in the current tab.
For example:
```text
file1.md
file2.md
file10.md
```
Folders are not included in the sequence, and files inside nested folders are
not scanned.
## Installation
### Manual Installation
1. Download `main.js` and `manifest.json` from the latest GitHub release.
2. Create this folder inside your vault:
```text
<vault>/.obsidian/plugins/explorer-stepper/
```
3. Copy both files into that folder.
4. Reload Obsidian.
5. Open **Settings → Community plugins** and enable **Explorer Stepper**.
## Configure Hotkeys
1. Open **Settings → Hotkeys**.
2. Search for `explorer-stepper` or **Explorer Stepper**.
3. Assign shortcuts to:
- **Explorer Stepper: Go to previous file**
- **Explorer Stepper: Go to next file**
The plugin intentionally does not provide default shortcuts to avoid conflicts
with existing Obsidian or plugin hotkeys.
![Explorer Stepper hotkey configuration](docs/explorer-stepper-hotkeys.png)
## Boundary Behavior
When the active file is already the first or last file in the sorted sequence,
Explorer Stepper stays on the current file and displays a localized notice.
Navigation does not wrap around.
If there is no active file, the plugin asks you to open one before using a
navigation command.
## Language Support
The plugin uses Chinese when the Obsidian locale starts with `zh`. All other
locales use English.
## Development
```bash
npm install
npm test
npm run build
```
The production build creates `main.js` in the project root.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

48
esbuild.config.mjs Normal file
View file

@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "node:process";
import { builtinModules } from "node:module";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD.
For the source code, visit the plugin repository.
*/
`;
const production = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules,
],
format: "cjs",
target: "es2021",
logLevel: "info",
sourcemap: production ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: production,
});
if (production) {
await context.rebuild();
await context.dispose();
} else {
await context.watch();
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "explorer-stepper",
"name": "Explorer Stepper",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "Navigate to the previous or next file in the current folder.",
"author": "kefate",
"authorUrl": "https://github.com/kefate",
"isDesktopOnly": false
}

1162
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "obsidian-explorer-stepper",
"version": "1.0.0",
"description": "Navigate to the previous or next file in the current folder.",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc --noEmit && node esbuild.config.mjs production",
"test": "tsx --test tests/*.test.ts"
},
"keywords": [
"obsidian",
"obsidian-plugin",
"navigation"
],
"author": "kefate",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^22.15.17",
"esbuild": "^0.25.5",
"obsidian": "latest",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

30
src/i18n.ts Normal file
View file

@ -0,0 +1,30 @@
export type TranslationKey =
| "commandPrevious"
| "commandNext"
| "noActiveFile"
| "firstFile"
| "lastFile";
type Translations = Record<TranslationKey, string>;
const ENGLISH_TRANSLATIONS: Translations = {
commandPrevious: "Go to previous file",
commandNext: "Go to next file",
noActiveFile: "Open a file before using Explorer Stepper.",
firstFile: "This is already the first file in the current folder.",
lastFile: "This is already the last file in the current folder.",
};
const CHINESE_TRANSLATIONS: Translations = {
commandPrevious: "跳转到上一个文件",
commandNext: "跳转到下一个文件",
noActiveFile: "请先打开一个文件。",
firstFile: "已经是当前文件夹中的第一个文件了。",
lastFile: "已经是当前文件夹中的最后一个文件了。",
};
export function getTranslations(locale: string): Translations {
return locale.toLowerCase().startsWith("zh")
? CHINESE_TRANSLATIONS
: ENGLISH_TRANSLATIONS;
}

66
src/main.ts Normal file
View file

@ -0,0 +1,66 @@
import { moment, Notice, Plugin, TFile } from "obsidian";
import { getTranslations } from "./i18n";
import {
findNavigationTarget,
type NavigationDirection,
} from "./navigation";
export default class ExplorerStepperPlugin extends Plugin {
override onload(): void {
const translations = getTranslations(this.getLocale());
this.addCommand({
id: "go-to-previous-file",
name: translations.commandPrevious,
callback: () => {
void this.navigate(-1);
},
});
this.addCommand({
id: "go-to-next-file",
name: translations.commandNext,
callback: () => {
void this.navigate(1);
},
});
}
private async navigate(direction: NavigationDirection): Promise<void> {
const locale = this.getLocale();
const translations = getTranslations(locale);
const currentFile = this.app.workspace.getActiveFile();
if (currentFile === null) {
new Notice(translations.noActiveFile);
return;
}
const siblingFiles = currentFile.parent?.children.filter(
(file): file is TFile => file instanceof TFile,
) ?? [currentFile];
const result = findNavigationTarget(
siblingFiles,
currentFile.path,
direction,
locale,
);
if (result.type === "first") {
new Notice(translations.firstFile);
return;
}
if (result.type === "last") {
new Notice(translations.lastFile);
return;
}
await this.app.workspace.getLeaf(false).openFile(result.file);
}
private getLocale(): string {
return moment.locale() || "en";
}
}

65
src/navigation.ts Normal file
View file

@ -0,0 +1,65 @@
export interface NavigableFile {
name: string;
path: string;
}
export type NavigationDirection = -1 | 1;
export type NavigationResult<T extends NavigableFile> =
| { type: "target"; file: T }
| { type: "first" }
| { type: "last" };
export function sortFilesNaturally<T extends NavigableFile>(
files: readonly T[],
locale: string,
): T[] {
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: "base",
});
return [...files].sort((left, right) => {
const nameOrder = collator.compare(left.name, right.name);
if (nameOrder !== 0) {
return nameOrder;
}
const pathOrder = collator.compare(left.path, right.path);
if (pathOrder !== 0) {
return pathOrder;
}
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
});
}
export function findNavigationTarget<T extends NavigableFile>(
files: readonly T[],
currentPath: string,
direction: NavigationDirection,
locale: string,
): NavigationResult<T> {
const sortedFiles = sortFilesNaturally(files, locale);
const currentIndex = sortedFiles.findIndex((file) => file.path === currentPath);
if (currentIndex === -1) {
return direction === -1 ? { type: "first" } : { type: "last" };
}
const targetIndex = currentIndex + direction;
if (targetIndex < 0) {
return { type: "first" };
}
if (targetIndex >= sortedFiles.length) {
return { type: "last" };
}
const target = sortedFiles[targetIndex];
if (target === undefined) {
return direction === -1 ? { type: "first" } : { type: "last" };
}
return { type: "target", file: target };
}

17
tests/i18n.test.ts Normal file
View file

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getTranslations } from "../src/i18n";
test("uses Chinese translations for Chinese locales", () => {
assert.equal(getTranslations("zh-cn").commandNext, "跳转到下一个文件");
assert.equal(getTranslations("ZH-TW").noActiveFile, "请先打开一个文件。");
});
test("uses English translations for other locales", () => {
assert.equal(getTranslations("en").commandPrevious, "Go to previous file");
assert.equal(
getTranslations("ja").lastFile,
"This is already the last file in the current folder.",
);
});

100
tests/navigation.test.ts Normal file
View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
findNavigationTarget,
sortFilesNaturally,
type NavigableFile,
} from "../src/navigation";
const file = (name: string, folder = "notes"): NavigableFile => ({
name,
path: `${folder}/${name}`,
});
test("sorts numeric file names naturally", () => {
const sorted = sortFilesNaturally(
[file("file10.md"), file("file2.md"), file("file1.md")],
"en",
);
assert.deepEqual(
sorted.map((item) => item.name),
["file1.md", "file2.md", "file10.md"],
);
});
test("sorts names without case sensitivity and uses paths as a tie breaker", () => {
const sorted = sortFilesNaturally(
[
{ name: "Note.md", path: "notes/z/Note.md" },
{ name: "note.md", path: "notes/a/note.md" },
],
"en",
);
assert.deepEqual(
sorted.map((item) => item.path),
["notes/a/note.md", "notes/z/Note.md"],
);
});
test("sorts Chinese names and mixed extensions", () => {
const sorted = sortFilesNaturally(
[file("第10章.pdf"), file("第2章.canvas"), file("第1章.md")],
"zh-CN",
);
assert.deepEqual(
sorted.map((item) => item.name),
["第1章.md", "第2章.canvas", "第10章.pdf"],
);
});
test("finds the previous and next files by exact path", () => {
const files = [file("3.pdf"), file("1.md"), file("2.png")];
assert.deepEqual(findNavigationTarget(files, "notes/2.png", -1, "en"), {
type: "target",
file: file("1.md"),
});
assert.deepEqual(findNavigationTarget(files, "notes/2.png", 1, "en"), {
type: "target",
file: file("3.pdf"),
});
});
test("returns boundaries without wrapping", () => {
const files = [file("1.md"), file("2.md")];
assert.deepEqual(findNavigationTarget(files, "notes/1.md", -1, "en"), {
type: "first",
});
assert.deepEqual(findNavigationTarget(files, "notes/2.md", 1, "en"), {
type: "last",
});
});
test("moves inward from both folder boundaries", () => {
const files = [file("1.md"), file("2.md"), file("3.md")];
assert.deepEqual(findNavigationTarget(files, "notes/3.md", -1, "en"), {
type: "target",
file: file("2.md"),
});
assert.deepEqual(findNavigationTarget(files, "notes/1.md", 1, "en"), {
type: "target",
file: file("2.md"),
});
});
test("treats a single-file folder as both boundaries", () => {
const files = [file("only.md")];
assert.deepEqual(findNavigationTarget(files, "notes/only.md", -1, "en"), {
type: "first",
});
assert.deepEqual(findNavigationTarget(files, "notes/only.md", 1, "en"), {
type: "last",
});
});

25
tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2021",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "Bundler",
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"lib": [
"ES2021",
"DOM"
]
},
"include": [
"src/**/*.ts",
"tests/**/*.ts"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.0.0"
}