Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ jobs:
exit 1
fi

- name: Compile
run: npm run compile
- name: Run tests
run: npm test

- name: Publish to Marketplace
run: npx vsce publish -p "${{ secrets.VSCE_PAT }}"
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Test

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test
2 changes: 2 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.vscode/**
.github/**
src/**
dist/test/**
.gitignore
tsconfig.json
**/*.map
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"test": "npm run compile && node --test \"dist/test/**/*.test.js\"",
"package": "vsce package"
},
"devDependencies": {
Expand Down
105 changes: 105 additions & 0 deletions src/docsPattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as path from 'path';

export const DEFAULT_DOCS_SETTING = '**/docs';
export const DEFAULT_FOLDER_NAME = 'docs';

export type DocsPattern = { recursive: boolean; tailSegments: string[] };

export type PartitionResult =
| { kind: 'grouped'; rootFiles: string[]; groups: Array<{ name: string; files: string[] }> }
| { kind: 'flat'; files: string[] };

export const isPrivateName = (name: string): boolean => name.startsWith('.');

export const byBasename = (a: string, b: string): number => path.basename(a).localeCompare(path.basename(b));

/**
* Parses one configured path entry into a search mode:
* - "**\/docs" -> recursive: matches a folder named "docs" anywhere in the workspace.
* - "docs" or "/docs" -> anchored: only the "docs" folder at the workspace root.
* A tail with multiple segments (e.g. "api/docs" or "**\/packages/docs") is matched as a whole suffix.
*/
export function parseDocsPattern(raw: string): DocsPattern {
const trimmed = (raw || DEFAULT_FOLDER_NAME).trim();
const recursive = trimmed.startsWith('**/');
const rest = recursive ? trimmed.slice(3) : trimmed;
const tailSegments = rest.split('/').map((s) => s.trim()).filter(Boolean);
return { recursive, tailSegments: tailSegments.length ? tailSegments : [DEFAULT_FOLDER_NAME] };
}

/** Splits the configured, comma-separated setting into individual patterns. */
export function parseDocsPatterns(raw: string): DocsPattern[] {
const entries = (raw || DEFAULT_DOCS_SETTING)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return (entries.length ? entries : [DEFAULT_DOCS_SETTING]).map(parseDocsPattern);
}

/** True if `dir`'s trailing path segments match `segments`, in order. */
export function endsWithSegments(dir: string, segments: string[]): boolean {
let current = dir;
for (let i = segments.length - 1; i >= 0; i--) {
if (path.basename(current) !== segments[i]) {
return false;
}
current = path.dirname(current);
}
return true;
}

/** Walks up from a file to the nearest ancestor directory whose trailing segments match `tailSegments`. */
export function findNearestDocsRoot(fileFsPath: string, tailSegments: string[]): string | undefined {
let dir = path.dirname(fileFsPath);
for (;;) {
if (endsWithSegments(dir, tailSegments)) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
return undefined;
}
dir = parent;
}
}

/** True if any path segment strictly below `rootPath` starts with a dot. */
export function hasPrivateSegmentBelow(rootPath: string, fsPath: string): boolean {
return path
.relative(rootPath, fsPath)
.split(path.sep)
.some(isPrivateName);
}

/** Splits one docs root's files into direct files and parent-folder-name groups (only when more than one group exists). */
export function partitionPaths(rootPath: string, filePaths: string[]): PartitionResult {
const rootFiles: string[] = [];
const groups = new Map<string, string[]>();

for (const fsPath of filePaths) {
const parentDir = path.dirname(fsPath);
if (parentDir === rootPath) {
rootFiles.push(fsPath);
} else {
const groupName = path.basename(parentDir);
const bucket = groups.get(groupName);
if (bucket) {
bucket.push(fsPath);
} else {
groups.set(groupName, [fsPath]);
}
}
}

const sortedRootFiles = [...rootFiles].sort(byBasename);

if (groups.size > 1) {
const groupEntries = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, files]) => ({ name, files: [...files].sort(byBasename) }));
return { kind: 'grouped', rootFiles: sortedRootFiles, groups: groupEntries };
}

const flatFiles = [...rootFiles, ...[...groups.values()].flat()].sort(byBasename);
return { kind: 'flat', files: flatFiles };
}
121 changes: 26 additions & 95 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import * as path from 'path';
import * as vscode from 'vscode';
import {
byBasename,
DEFAULT_DOCS_SETTING,
DocsPattern,
findNearestDocsRoot,
hasPrivateSegmentBelow,
isPrivateName,
parseDocsPatterns,
partitionPaths,
} from './docsPattern';

const byUriBasename = (a: vscode.Uri, b: vscode.Uri) => byBasename(a.fsPath, b.fsPath);

const CONFIG_SECTION = 'docsPanel';
const CONFIG_KEY_DOCS_PATH = 'docsPath';
const CONFIG_KEY_SKIP_PRIVATE = 'skipPrivate';
const DEFAULT_DOCS_SETTING = '**/docs';
const DEFAULT_FOLDER_NAME = 'docs';
const EXCLUDE_GLOB = '**/{node_modules,.git}/**';

type DocEntry =
| { kind: 'root'; path: string; label: string }
| { kind: 'group'; rootPath: string; name: string; files: vscode.Uri[] }
| { kind: 'file'; uri: vscode.Uri };

type DocsPattern = { recursive: boolean; tailSegments: string[] };

const byBasename = (a: vscode.Uri, b: vscode.Uri) =>
path.basename(a.fsPath).localeCompare(path.basename(b.fsPath));

function getConfiguredDocsPath(): string {
return vscode.workspace.getConfiguration(CONFIG_SECTION).get<string>(CONFIG_KEY_DOCS_PATH) ?? DEFAULT_DOCS_SETTING;
}
Expand All @@ -26,58 +31,6 @@ function getConfiguredSkipPrivate(): boolean {
return vscode.workspace.getConfiguration(CONFIG_SECTION).get<boolean>(CONFIG_KEY_SKIP_PRIVATE) ?? true;
}

const isPrivateName = (name: string) => name.startsWith('.');

/**
* Parses one configured path entry into a search mode:
* - "**\/docs" -> recursive: matches a folder named "docs" anywhere in the workspace.
* - "docs" or "/docs" -> anchored: only the "docs" folder at the workspace root.
* A tail with multiple segments (e.g. "api/docs" or "**\/packages/docs") is matched as a whole suffix.
*/
function parseDocsPattern(raw: string): DocsPattern {
const trimmed = (raw || DEFAULT_FOLDER_NAME).trim();
const recursive = trimmed.startsWith('**/');
const rest = recursive ? trimmed.slice(3) : trimmed;
const tailSegments = rest.split('/').map((s) => s.trim()).filter(Boolean);
return { recursive, tailSegments: tailSegments.length ? tailSegments : [DEFAULT_FOLDER_NAME] };
}

/** Splits the configured, comma-separated setting into individual patterns. */
function parseDocsPatterns(raw: string): DocsPattern[] {
const entries = (raw || DEFAULT_DOCS_SETTING)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return (entries.length ? entries : [DEFAULT_DOCS_SETTING]).map(parseDocsPattern);
}

/** True if `dir`'s trailing path segments match `segments`, in order. */
function endsWithSegments(dir: string, segments: string[]): boolean {
let current = dir;
for (let i = segments.length - 1; i >= 0; i--) {
if (path.basename(current) !== segments[i]) {
return false;
}
current = path.dirname(current);
}
return true;
}

/** Walks up from a file to the nearest ancestor directory whose trailing segments match `tailSegments`. */
function findNearestDocsRoot(fileFsPath: string, tailSegments: string[]): string | undefined {
let dir = path.dirname(fileFsPath);
for (;;) {
if (endsWithSegments(dir, tailSegments)) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
return undefined;
}
dir = parent;
}
}

/** Recursively collects every markdown file under a directory, optionally skipping dot-prefixed entries. */
async function collectMarkdownFiles(dirUri: vscode.Uri, skipPrivate: boolean): Promise<vscode.Uri[]> {
let entries: [string, vscode.FileType][];
Expand All @@ -102,14 +55,6 @@ async function collectMarkdownFiles(dirUri: vscode.Uri, skipPrivate: boolean): P
return results;
}

/** True if any path segment strictly below `rootPath` starts with a dot. */
function hasPrivateSegmentBelow(rootPath: string, fsPath: string): boolean {
return path
.relative(rootPath, fsPath)
.split(path.sep)
.some(isPrivateName);
}

/** Finds markdown files for a single parsed pattern; see `discoverDocs` for the two modes. */
async function discoverForPattern(
folder: vscode.WorkspaceFolder,
Expand Down Expand Up @@ -182,37 +127,23 @@ async function discoverDocs(patterns: DocsPattern[], skipPrivate: boolean): Prom
return merged;
}

/** Splits one docs root's files into direct files and parent-folder-name groups (only when more than one group exists). */
/** Splits one docs root's files into direct files and parent-folder-name groups, using the shared partitioning rule. */
function partition(rootPath: string, files: vscode.Uri[]): DocEntry[] {
const rootFiles: vscode.Uri[] = [];
const groups = new Map<string, vscode.Uri[]>();

for (const uri of files) {
const parentDir = path.dirname(uri.fsPath);
if (parentDir === rootPath) {
rootFiles.push(uri);
} else {
const groupName = path.basename(parentDir);
const bucket = groups.get(groupName);
if (bucket) {
bucket.push(uri);
} else {
groups.set(groupName, [uri]);
}
}
}

const rootFileEntries: DocEntry[] = rootFiles.sort(byBasename).map((uri) => ({ kind: 'file', uri }));
const byPath = new Map(files.map((uri) => [uri.fsPath, uri] as const));
const result = partitionPaths(rootPath, files.map((uri) => uri.fsPath));

if (groups.size > 1) {
const groupEntries: DocEntry[] = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, groupFiles]) => ({ kind: 'group', rootPath, name, files: groupFiles }));
return [...rootFileEntries, ...groupEntries];
if (result.kind === 'flat') {
return result.files.map((fsPath): DocEntry => ({ kind: 'file', uri: byPath.get(fsPath)! }));
}

const flatFiles = [...rootFiles, ...[...groups.values()].flat()];
return flatFiles.sort(byBasename).map((uri) => ({ kind: 'file', uri }));
const rootFileEntries: DocEntry[] = result.rootFiles.map((fsPath) => ({ kind: 'file', uri: byPath.get(fsPath)! }));
const groupEntries: DocEntry[] = result.groups.map((group) => ({
kind: 'group',
rootPath,
name: group.name,
files: group.files.map((fsPath) => byPath.get(fsPath)!),
}));
return [...rootFileEntries, ...groupEntries];
}

class DocsTreeProvider implements vscode.TreeDataProvider<DocEntry> {
Expand Down Expand Up @@ -260,7 +191,7 @@ class DocsTreeProvider implements vscode.TreeDataProvider<DocEntry> {
return [];
}
if (element?.kind === 'group') {
return [...element.files].sort(byBasename).map((uri) => ({ kind: 'file', uri }));
return [...element.files].sort(byUriBasename).map((uri) => ({ kind: 'file', uri }));
}
if (element?.kind === 'root') {
return partition(element.path, this.roots.get(element.path) ?? []);
Expand Down
Loading
Loading