mirror of
https://github.com/kefate/obsidian-explorer-stepper.git
synced 2026-07-22 07:47:56 +00:00
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:
parent
d859a6dc11
commit
1e62778cd4
15 changed files with 1653 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
main.js
|
||||
*.map
|
||||
data.json
|
||||
.DS_Store
|
||||
84
README.md
Normal file
84
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
BIN
docs/explorer-stepper-hotkeys.png
Normal file
BIN
docs/explorer-stepper-hotkeys.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
48
esbuild.config.mjs
Normal file
48
esbuild.config.mjs
Normal 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
10
manifest.json
Normal 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
1162
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal 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
30
src/i18n.ts
Normal 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
66
src/main.ts
Normal 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
65
src/navigation.ts
Normal 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
17
tests/i18n.test.ts
Normal 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
100
tests/navigation.test.ts
Normal 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
25
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.0.0"
|
||||
}
|
||||
Loading…
Reference in a new issue