diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml new file mode 100644 index 0000000..f4ad398 --- /dev/null +++ b/.github/workflows/package-validation.yml @@ -0,0 +1,38 @@ +name: Package Validation + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + validate: + runs-on: [self-hosted] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install VS Code test dependencies + if: runner.os == 'Linux' + run: | + if ! dpkg -s libgbm1 xvfb >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y libgbm1 xvfb + fi + + - run: npm ci + - run: npm run compile + - run: npm run lint + - run: xvfb-run -a npm test + if: runner.os == 'Linux' + - run: npm test + if: runner.os != 'Linux' + - run: npx @vscode/vsce package --no-dependencies diff --git a/.vscodeignore b/.vscodeignore index 7d3e5c7..e4159a6 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,4 +1,5 @@ .vscode/** +.github/** .vscode-test/** src/** .gitignore @@ -8,4 +9,5 @@ vsc-extension-quickstart.md **/eslint.config.mjs **/*.map **/*.ts +out/test/** **/.vscode-test.* diff --git a/CHANGELOG.md b/CHANGELOG.md index d2afcf3..1e0c16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Notable changes to Tree Generator are listed by release. +## [0.4.0] - 2026-08-13 + +### Added + +- Extension icon and Marketplace package metadata. +- Markdown target diagnostics and one-click tree block setup. +- Markdown target, output style, and maximum depth controls in the Webview. +- Undo and redo for tree ordering, exclusions, descriptions, and bulk actions. +- Root `.tree-generatorignore` support independent of `.gitignore` settings. +- Directory-level metadata reset. +- Package validation workflow for a self-hosted GitHub Actions runner. + ## [0.3.0] - 2026-08-12 ### Added diff --git a/README.md b/README.md index acc98e2..02a559a 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ tree-generator/ - Directory-first alphabetical scanning with `.gitignore` support. - Drag-and-drop ordering, descriptions, and manual exclusions. - Search and directory-wide include or exclude actions. -- Automatic refresh when files, folders, or `.gitignore` files change. +- Undo and redo for tree editing, plus per-directory reset. +- Automatic refresh when files, folders, or ignore files change. - Unicode or ASCII output with an optional maximum depth. +- Output settings and Markdown setup diagnostics in the Webview. +- Tree-only exclusions through a root `.tree-generatorignore` file. - Project metadata persistence in `.tree-generator.json`. - Multi-root workspace folder selection. @@ -27,9 +30,9 @@ tree-generator/ 1. Install [Tree Generator](https://marketplace.visualstudio.com/items?itemName=dldyou.tree-generator-dldyou) and open a workspace. 2. Run `Tree Generator: Open Tree Editor` from the Command Palette. -3. Edit the tree, then copy the preview or enable automatic Markdown updates. +3. Edit the tree, configure the output, then copy the preview or enable automatic Markdown updates. -To update a Markdown file automatically, add this block after removing the backslashes from the comments: +Use **Set up Markdown** in the editor to create the target file or append the required tree block. The generated block has this form: ````md <\!-- tree-generator:start --> diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..8de9f10 Binary files /dev/null and b/images/icon.png differ diff --git a/package-lock.json b/package-lock.json index 66f3b69..f101946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tree-generator", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tree-generator", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "ignore": "^7.0.5" }, diff --git a/package.json b/package.json index e719a02..b4cc159 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tree-generator-dldyou", "displayName": "Tree Generator", "description": "Generate project tree for README.md", - "version": "0.3.0", + "version": "0.4.0", "repository": { "type": "git", "url": "https://github.com/dldyou/tree-generator.git" @@ -15,6 +15,7 @@ ], "activationEvents": [], "main": "./out/extension.js", + "icon": "images/icon.png", "bin": { "tree-generator": "./out/cli.js" }, diff --git a/src/extension.ts b/src/extension.ts index 7c5e391..2652a2a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,22 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { updateReadmeTreeBlock } from './readmeUpdater'; +import { + ensureReadmeTreeBlock, + inspectReadmeTreeBlock, + ReadmeSetupStatus, + updateReadmeTreeBlock, +} from './readmeUpdater'; import { scanDirectory, ScanOptions } from './scanner'; -import { deleteTreeStateFile, loadTreeStateFile, saveTreeStateFile } from './treeMetaStore'; +import { + deleteTreeStateFile, + loadTreeStateFile, + saveTreeStateFile, + TREE_METADATA_FILE_NAME, +} from './treeMetaStore'; import { generateTreeString, TreeGeneratorOptions } from './treeGenerator'; import { reorderChildren, + resetDirectory, setDescendantsExcluded, setNodeDescription, setNodeExcluded, @@ -14,6 +25,26 @@ import { applyTreeState, captureTreeState, PersistedTreeState } from './treeStat import { TreeNode } from './types'; import { getTreeEditorHtml } from './webview'; +function cloneTree(node: TreeNode): TreeNode { + return { + ...node, + children: node.children?.map(cloneTree), + }; +} + +export function shouldScheduleFileTreeRefresh( + rootPath: string, + changedPath: string, +): boolean { + if (path.basename(changedPath) === '.gitignore') { + return false; + } + + const resolvedChangedPath = path.resolve(changedPath); + return resolvedChangedPath !== path.resolve(rootPath, '.tree-generatorignore') + && resolvedChangedPath !== path.resolve(rootPath, TREE_METADATA_FILE_NAME); +} + export function activate(context: vscode.ExtensionContext) { const disposable = vscode.commands.registerCommand('tree-generator.generateTree', async () => { const workspaceFolders = vscode.workspace.workspaceFolders; @@ -92,6 +123,41 @@ function getGeneratorOptions(rootPath: string): TreeGeneratorOptions { }; } +function getReadmeDiagnostic( + status: ReadmeSetupStatus, + autoUpdateReadme: boolean, + targetPath: string, +): { text: string; canSetup: boolean; isError: boolean } { + switch (status) { + case 'missing-file': + return { + text: `${targetPath} does not exist. Set it up to create the Markdown tree block.`, + canSetup: true, + isError: false, + }; + case 'missing-markers': + return { + text: `${targetPath} has no tree markers, so automatic updates cannot run.`, + canSetup: true, + isError: false, + }; + case 'incomplete-markers': + return { + text: `${targetPath} does not contain a valid start/end tree marker pair. Fix the markers before setup.`, + canSetup: false, + isError: true, + }; + default: + return { + text: autoUpdateReadme + ? `${targetPath} is ready for automatic updates.` + : 'Automatic Markdown updates are disabled.', + canSetup: false, + isError: false, + }; + } +} + async function loadSavedTreeState( context: vscode.ExtensionContext, rootPath: string, @@ -133,30 +199,63 @@ function openTreeEditor( let scanOptions = initialScanOptions; let refreshTimer: NodeJS.Timeout | undefined; let pendingRefreshStatus = 'Tree refreshed'; + const undoStack: TreeNode[] = []; + const redoStack: TreeNode[] = []; + + const recordMutation = (previousTree: TreeNode): void => { + undoStack.push(previousTree); + if (undoStack.length > 50) { + undoStack.shift(); + } + redoStack.length = 0; + }; const saveTree = async (): Promise => { await saveTreeStateFile(rootPath, captureTreeState(tree)); }; const sendUpdate = async (status?: string): Promise => { - const treeString = generateTreeString(tree, getGeneratorOptions(rootPath)); + const generatorOptions = getGeneratorOptions(rootPath); + const treeString = generateTreeString(tree, generatorOptions); let readmeUpdateError: string | undefined; const autoUpdateReadme = getAutoUpdateReadme(rootPath); + const readmePath = getReadmePath(rootPath); if (autoUpdateReadme) { try { - await updateReadmeTreeBlock(rootPath, treeString, getReadmePath(rootPath)); + await updateReadmeTreeBlock(rootPath, treeString, readmePath); } catch (error) { readmeUpdateError = `Failed to update markdown file: ${String(error)}`; } } + let readmeDiagnostic; + try { + readmeDiagnostic = getReadmeDiagnostic( + await inspectReadmeTreeBlock(rootPath, readmePath), + autoUpdateReadme, + readmePath, + ); + } catch (error) { + readmeDiagnostic = { + text: `Could not inspect ${readmePath}: ${String(error)}`, + canSetup: false, + isError: true, + }; + } + await panel.webview.postMessage({ type: 'update', tree, treeString, respectGitignore: scanOptions.respectGitignore, autoUpdateReadme, + readmeDiagnostic, + readmePath, + outputStyle: generatorOptions.style ?? 'unicode', + maxDepth: generatorOptions.maxDepth ?? -1, + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, status, }); @@ -178,6 +277,8 @@ function openTreeEditor( } tree = refreshedTree; + undoStack.length = 0; + redoStack.length = 0; await sendUpdate(status); } catch (error) { await panel.webview.postMessage({ @@ -219,8 +320,34 @@ function openTreeEditor( ); }; + const updateOutputSettings = async ( + readmePath: string, + outputStyle: 'unicode' | 'ascii', + maxDepth: number, + ): Promise => { + const configuration = vscode.workspace.getConfiguration( + 'tree-generator', + vscode.Uri.file(rootPath), + ); + await configuration.update( + 'readmePath', + readmePath, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await configuration.update( + 'outputStyle', + outputStyle, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await configuration.update( + 'maxDepth', + maxDepth, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + }; + const scheduleFileTreeRefresh = (uri: vscode.Uri): void => { - if (path.basename(uri.fsPath) === '.gitignore') { + if (!shouldScheduleFileTreeRefresh(rootPath, uri.fsPath)) { return; } @@ -230,6 +357,9 @@ function openTreeEditor( const gitignoreWatcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(rootPath, '**/.gitignore'), ); + const treeIgnoreWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(rootPath, '.tree-generatorignore'), + ); const fileTreeWatcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(rootPath, '**/*'), ); @@ -237,6 +367,9 @@ function openTreeEditor( gitignoreWatcher.onDidCreate(() => scheduleRefresh('.gitignore changed; tree refreshed')), gitignoreWatcher.onDidChange(() => scheduleRefresh('.gitignore changed; tree refreshed')), gitignoreWatcher.onDidDelete(() => scheduleRefresh('.gitignore changed; tree refreshed')), + treeIgnoreWatcher.onDidCreate(() => scheduleRefresh('.tree-generatorignore changed; tree refreshed')), + treeIgnoreWatcher.onDidChange(() => scheduleRefresh('.tree-generatorignore changed; tree refreshed')), + treeIgnoreWatcher.onDidDelete(() => scheduleRefresh('.tree-generatorignore changed; tree refreshed')), fileTreeWatcher.onDidCreate(scheduleFileTreeRefresh), fileTreeWatcher.onDidDelete(scheduleFileTreeRefresh), vscode.workspace.onDidChangeConfiguration(event => { @@ -291,6 +424,7 @@ function openTreeEditor( await sendUpdate(); break; case 'reorder': + const treeBeforeReorder = cloneTree(tree); if ( typeof message.parentPath !== 'string' || !Array.isArray(message.orderedChildPaths) @@ -308,10 +442,12 @@ function openTreeEditor( break; } + recordMutation(treeBeforeReorder); await saveTree(); await sendUpdate('Order updated'); break; case 'setExcluded': + const treeBeforeExclusion = cloneTree(tree); if ( typeof message.nodePath !== 'string' || typeof message.excluded !== 'boolean' @@ -326,6 +462,7 @@ function openTreeEditor( break; } + recordMutation(treeBeforeExclusion); await saveTree(); await sendUpdate( message.excluded @@ -334,6 +471,7 @@ function openTreeEditor( ); break; case 'setDescendantsExcluded': + const treeBeforeBulkExclusion = cloneTree(tree); if ( typeof message.directoryPath !== 'string' || typeof message.excluded !== 'boolean' @@ -351,6 +489,7 @@ function openTreeEditor( break; } + recordMutation(treeBeforeBulkExclusion); await saveTree(); await sendUpdate( message.excluded @@ -359,6 +498,7 @@ function openTreeEditor( ); break; case 'setDescription': + const treeBeforeDescription = cloneTree(tree); if ( typeof message.nodePath !== 'string' || typeof message.description !== 'string' @@ -373,6 +513,7 @@ function openTreeEditor( break; } + recordMutation(treeBeforeDescription); await saveTree(); await sendUpdate('Description updated'); break; @@ -400,6 +541,37 @@ function openTreeEditor( await updateAutoUpdateReadme(message.autoUpdateReadme); break; + case 'setupMarkdown': + await ensureReadmeTreeBlock( + rootPath, + generateTreeString(tree, getGeneratorOptions(rootPath)), + getReadmePath(rootPath), + ); + await sendUpdate('Markdown tree block created'); + break; + case 'setOutputSettings': + if ( + typeof message.readmePath !== 'string' + || message.readmePath.trim().length === 0 + || (message.outputStyle !== 'unicode' && message.outputStyle !== 'ascii') + || !Number.isInteger(message.maxDepth) + || message.maxDepth < -1 + ) { + await panel.webview.postMessage({ + type: 'status', + text: 'Could not update output settings.', + isError: true, + }); + break; + } + + await updateOutputSettings( + message.readmePath.trim(), + message.outputStyle, + message.maxDepth, + ); + await sendUpdate('Output settings updated'); + break; case 'copy': await vscode.env.clipboard.writeText( generateTreeString(tree, getGeneratorOptions(rootPath)), @@ -410,11 +582,53 @@ function openTreeEditor( }); break; case 'reset': + const treeBeforeReset = cloneTree(tree); + const defaultTree = await scanDirectory(rootPath, scanOptions); await deleteTreeStateFile(rootPath); await context.workspaceState.update(stateKey, undefined); - tree = await scanDirectory(rootPath, scanOptions); + tree = defaultTree; + recordMutation(treeBeforeReset); await sendUpdate('Default order restored'); break; + case 'resetDirectory': + if (typeof message.directoryPath !== 'string') { + break; + } + const treeBeforeDirectoryReset = cloneTree(tree); + if (!resetDirectory(tree, message.directoryPath)) { + await panel.webview.postMessage({ + type: 'status', + text: 'Could not reset that directory.', + isError: true, + }); + break; + } + recordMutation(treeBeforeDirectoryReset); + await saveTree(); + await sendUpdate('Directory restored to default'); + break; + case 'undo': + const previousTree = undoStack.pop(); + if (!previousTree) { + await sendUpdate(); + break; + } + redoStack.push(cloneTree(tree)); + tree = previousTree; + await saveTree(); + await sendUpdate('Change undone'); + break; + case 'redo': + const nextTree = redoStack.pop(); + if (!nextTree) { + await sendUpdate(); + break; + } + undoStack.push(cloneTree(tree)); + tree = nextTree; + await saveTree(); + await sendUpdate('Change restored'); + break; } } catch (error) { await panel.webview.postMessage({ @@ -432,6 +646,7 @@ function openTreeEditor( messageDisposable.dispose(); watcherDisposables.forEach(disposable => disposable.dispose()); gitignoreWatcher.dispose(); + treeIgnoreWatcher.dispose(); fileTreeWatcher.dispose(); }); panel.webview.html = getTreeEditorHtml(panel.webview); diff --git a/src/readmeUpdater.ts b/src/readmeUpdater.ts index c4bc6da..c6d6d8e 100644 --- a/src/readmeUpdater.ts +++ b/src/readmeUpdater.ts @@ -14,6 +14,12 @@ export interface ReadmeCheckResult { matches: boolean; } +export type ReadmeSetupStatus = + | 'ready' + | 'missing-file' + | 'missing-markers' + | 'incomplete-markers'; + function resolveReadmePath(rootPath: string, targetPath = 'README.md'): string { if (path.isAbsolute(targetPath)) { throw new Error('README target path must be relative and within the root path.'); @@ -39,6 +45,77 @@ export function renderReadmeTreeBlock(treeString: string): string { ].join('\n'); } +function getSetupStatus(readme: string): ReadmeSetupStatus { + const startIndex = readme.indexOf(README_TREE_START_MARKER); + const anyEndIndex = readme.indexOf(README_TREE_END_MARKER); + if (startIndex === -1 && anyEndIndex === -1) { + return 'missing-markers'; + } + + const matchingEndIndex = startIndex === -1 + ? -1 + : readme.indexOf( + README_TREE_END_MARKER, + startIndex + README_TREE_START_MARKER.length, + ); + if (matchingEndIndex !== -1) { + return 'ready'; + } + return 'incomplete-markers'; +} + +export async function inspectReadmeTreeBlock( + rootPath: string, + targetPath?: string, +): Promise { + try { + return getSetupStatus( + await fs.readFile(resolveReadmePath(rootPath, targetPath), 'utf8'), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 'missing-file'; + } + throw error; + } +} + +export async function ensureReadmeTreeBlock( + rootPath: string, + treeString: string, + targetPath?: string, +): Promise { + const target = resolveReadmePath(rootPath, targetPath); + let readme = ''; + try { + readme = await fs.readFile(target, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + await fs.mkdir(path.dirname(target), { recursive: true }); + } + + const status = getSetupStatus(readme); + if (status === 'incomplete-markers') { + throw new Error('Markdown target does not contain an ordered tree marker pair. Fix the markers and try again.'); + } + + if (status === 'ready') { + await updateReadmeTreeBlock(rootPath, treeString, targetPath); + return; + } + + const separator = readme.length === 0 || readme.endsWith('\n\n') + ? '' + : readme.endsWith('\n') ? '\n' : '\n\n'; + await fs.writeFile( + target, + `${readme}${separator}${renderReadmeTreeBlock(treeString)}\n`, + 'utf8', + ); +} + export function replaceReadmeTreeBlock( readme: string, treeString: string, diff --git a/src/scanner.ts b/src/scanner.ts index bdab6ac..a4b1644 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -15,9 +15,10 @@ interface IgnoreScope { async function loadIgnoreScopes( dirPath: string, inheritedScopes: IgnoreScope[], + ignoreFileName: string, ): Promise { try { - const patterns = await fs.readFile(path.join(dirPath, '.gitignore'), 'utf8'); + const patterns = await fs.readFile(path.join(dirPath, ignoreFileName), 'utf8'); return [ ...inheritedScopes, { @@ -60,12 +61,13 @@ function isIgnored( async function scanDirectoryWithScopes( dirPath: string, + treeIgnoreScopes: IgnoreScope[], inheritedScopes: IgnoreScope[], options: Required, ): Promise { const name = path.basename(dirPath); const scopes = options.respectGitignore - ? await loadIgnoreScopes(dirPath, inheritedScopes) + ? await loadIgnoreScopes(dirPath, inheritedScopes, '.gitignore') : inheritedScopes; const root: TreeNode = { @@ -81,12 +83,16 @@ async function scanDirectoryWithScopes( return false; } - return !options.respectGitignore + return !isIgnored( + path.join(dirPath, entry.name), + entry.isDirectory(), + treeIgnoreScopes, + ) && (!options.respectGitignore || !isIgnored( path.join(dirPath, entry.name), entry.isDirectory(), scopes, - ); + )); }); filteredEntries.sort((a, b) => { @@ -99,7 +105,12 @@ async function scanDirectoryWithScopes( for (const entry of filteredEntries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - const childNode = await scanDirectoryWithScopes(fullPath, scopes, options); + const childNode = await scanDirectoryWithScopes( + fullPath, + treeIgnoreScopes, + scopes, + options, + ); root.children!.push(childNode); } else if (entry.isFile()) { root.children!.push({ @@ -117,7 +128,13 @@ export async function scanDirectory( dirPath: string, options: ScanOptions = {}, ): Promise { - return scanDirectoryWithScopes(dirPath, [], { + const treeIgnoreScopes = await loadIgnoreScopes( + dirPath, + [], + '.tree-generatorignore', + ); + + return scanDirectoryWithScopes(dirPath, treeIgnoreScopes, [], { respectGitignore: options.respectGitignore ?? true, }); } diff --git a/src/test/fileWatcher.test.ts b/src/test/fileWatcher.test.ts new file mode 100644 index 0000000..8c0c1b5 --- /dev/null +++ b/src/test/fileWatcher.test.ts @@ -0,0 +1,39 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { shouldScheduleFileTreeRefresh } from '../extension'; +import { TREE_METADATA_FILE_NAME } from '../treeMetaStore'; + +suite('File watcher filtering', () => { + test('ignores internal root files without hiding nested tree ignore files', () => { + const rootPath = path.resolve('workspace', 'project'); + + assert.strictEqual( + shouldScheduleFileTreeRefresh( + rootPath, + path.join(rootPath, TREE_METADATA_FILE_NAME), + ), + false, + ); + assert.strictEqual( + shouldScheduleFileTreeRefresh( + rootPath, + path.join(rootPath, '.tree-generatorignore'), + ), + false, + ); + assert.strictEqual( + shouldScheduleFileTreeRefresh( + rootPath, + path.join(rootPath, 'nested', '.tree-generatorignore'), + ), + true, + ); + assert.strictEqual( + shouldScheduleFileTreeRefresh( + rootPath, + path.join(rootPath, 'nested', '.gitignore'), + ), + false, + ); + }); +}); diff --git a/src/test/readmeTargetFile.test.ts b/src/test/readmeTargetFile.test.ts index 82b0121..8c846b1 100644 --- a/src/test/readmeTargetFile.test.ts +++ b/src/test/readmeTargetFile.test.ts @@ -4,6 +4,8 @@ import * as os from 'os'; import * as path from 'path'; import { checkReadmeTreeBlock, + ensureReadmeTreeBlock, + inspectReadmeTreeBlock, README_TREE_END_MARKER, README_TREE_START_MARKER, updateReadmeTreeBlock, @@ -11,6 +13,74 @@ import { import { runCli } from '../cli'; suite('README target file', () => { + test('creates a missing target file with tree markers', async () => { + const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); + + try { + assert.strictEqual( + await inspectReadmeTreeBlock(rootPath, 'docs/structure.md'), + 'missing-file', + ); + await ensureReadmeTreeBlock(rootPath, 'project/\n', 'docs/structure.md'); + const content = await fs.readFile(path.join(rootPath, 'docs', 'structure.md'), 'utf8'); + assert.ok(content.includes(README_TREE_START_MARKER)); + assert.ok(content.includes(README_TREE_END_MARKER)); + assert.strictEqual( + await inspectReadmeTreeBlock(rootPath, 'docs/structure.md'), + 'ready', + ); + } finally { + await fs.rm(rootPath, { recursive: true, force: true }); + } + }); + + test('appends markers without replacing existing Markdown', async () => { + const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); + + try { + await fs.writeFile(path.join(rootPath, 'README.md'), '# Project\n'); + await ensureReadmeTreeBlock(rootPath, 'project/\n'); + const content = await fs.readFile(path.join(rootPath, 'README.md'), 'utf8'); + assert.ok(content.startsWith('# Project\n')); + assert.ok(content.includes(README_TREE_START_MARKER)); + } finally { + await fs.rm(rootPath, { recursive: true, force: true }); + } + }); + + test('reports incomplete marker pairs', async () => { + const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); + + try { + await fs.writeFile(path.join(rootPath, 'README.md'), README_TREE_START_MARKER); + assert.strictEqual(await inspectReadmeTreeBlock(rootPath), 'incomplete-markers'); + await assert.rejects( + ensureReadmeTreeBlock(rootPath, 'project/\n'), + /ordered tree marker pair/, + ); + } finally { + await fs.rm(rootPath, { recursive: true, force: true }); + } + }); + + test('rejects an end marker that appears before the start marker', async () => { + const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); + + try { + await fs.writeFile( + path.join(rootPath, 'README.md'), + [README_TREE_END_MARKER, README_TREE_START_MARKER].join('\n'), + ); + assert.strictEqual(await inspectReadmeTreeBlock(rootPath), 'incomplete-markers'); + await assert.rejects( + ensureReadmeTreeBlock(rootPath, 'project/\n'), + /ordered tree marker pair/, + ); + } finally { + await fs.rm(rootPath, { recursive: true, force: true }); + } + }); + test('updates nested targets, defaults to README.md, and rejects unsafe paths', async () => { const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); const nestedReadmePath = path.join(rootPath, 'docs', 'guide.md'); diff --git a/src/test/scanner.test.ts b/src/test/scanner.test.ts new file mode 100644 index 0000000..15f1a06 --- /dev/null +++ b/src/test/scanner.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { scanDirectory } from '../scanner'; + +suite('Scanner Test Suite', () => { + test('Always applies root .tree-generatorignore rules', async () => { + const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'tree-generator-')); + + try { + await fs.writeFile(path.join(rootPath, '.tree-generatorignore'), '*.log\n'); + await fs.writeFile(path.join(rootPath, '.gitignore'), '*.tmp\n'); + await fs.writeFile(path.join(rootPath, 'hidden.log'), ''); + await fs.writeFile(path.join(rootPath, 'included.tmp'), ''); + + const tree = await scanDirectory(rootPath, { respectGitignore: false }); + + assert.deepStrictEqual( + tree.children?.map(child => child.name), + ['.gitignore', '.tree-generatorignore', 'included.tmp'], + ); + } finally { + await fs.rm(rootPath, { recursive: true, force: true }); + } + }); +}); diff --git a/src/test/treeDirectoryReset.test.ts b/src/test/treeDirectoryReset.test.ts new file mode 100644 index 0000000..7490c11 --- /dev/null +++ b/src/test/treeDirectoryReset.test.ts @@ -0,0 +1,65 @@ +import * as assert from 'assert'; +import { resetDirectory } from '../treeOrdering'; +import { TreeNode } from '../types'; + +suite('Directory reset', () => { + test('resets only the selected directory subtree', () => { + const tree: TreeNode = { + name: 'root', + path: '/root', + type: 'directory', + children: [ + { + name: 'src', + path: '/root/src', + type: 'directory', + excluded: true, + description: 'source', + children: [ + { name: 'z.ts', path: '/root/src/z.ts', type: 'file', excluded: true }, + { name: 'a.ts', path: '/root/src/a.ts', type: 'file', description: 'entry' }, + { + name: 'lib', + path: '/root/src/lib', + type: 'directory', + excluded: true, + children: [], + }, + ], + }, + { + name: 'docs', + path: '/root/docs', + type: 'directory', + children: [ + { name: 'z.md', path: '/root/docs/z.md', type: 'file' }, + { name: 'a.md', path: '/root/docs/a.md', type: 'file', excluded: true }, + ], + }, + ], + }; + + assert.strictEqual(resetDirectory(tree, '/root/src'), true); + const src = tree.children?.[0]; + assert.strictEqual(src?.description, undefined); + assert.strictEqual(src?.excluded, undefined); + assert.deepStrictEqual(src?.children?.map(child => child.name), ['lib', 'a.ts', 'z.ts']); + assert.ok(src?.children?.every(child => !child.excluded && !child.description)); + assert.deepStrictEqual( + tree.children?.[1].children?.map(child => [child.name, child.excluded]), + [['z.md', undefined], ['a.md', true]], + ); + }); + + test('rejects files and unknown paths', () => { + const tree: TreeNode = { + name: 'root', + path: '/root', + type: 'directory', + children: [{ name: 'a.ts', path: '/root/a.ts', type: 'file' }], + }; + + assert.strictEqual(resetDirectory(tree, '/root/a.ts'), false); + assert.strictEqual(resetDirectory(tree, '/root/missing'), false); + }); +}); diff --git a/src/treeOrdering.ts b/src/treeOrdering.ts index bf9cb3c..3ae085a 100644 --- a/src/treeOrdering.ts +++ b/src/treeOrdering.ts @@ -128,3 +128,31 @@ export function setNodeDescription( node.description = normalizedDescription || undefined; return true; } + +export function resetDirectory( + root: TreeNode, + directoryPath: string, +): boolean { + const directory = findNode(root, directoryPath)?.node; + if (!directory || directory.type !== 'directory') { + return false; + } + + const resetNode = (node: TreeNode): void => { + node.excluded = undefined; + node.description = undefined; + for (const child of node.children ?? []) { + child.excluded = undefined; + resetNode(child); + } + node.children?.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + }; + + resetNode(directory); + return true; +} diff --git a/src/webview.ts b/src/webview.ts index 7d42cce..0b7752d 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -288,6 +288,48 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { color: var(--vscode-errorForeground); } + .diagnostic { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + margin: -4px 0 12px; + color: var(--vscode-descriptionForeground); + } + + .diagnostic.error { + color: var(--vscode-errorForeground); + } + + .settings { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; + } + + .setting { + display: grid; + gap: 4px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + } + + .setting input, + .setting select { + min-width: 110px; + height: 28px; + border: 1px solid var(--vscode-input-border, transparent); + padding: 2px 7px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + } + + .setting:first-child input { + min-width: 220px; + } + @media (max-width: 760px) { .layout { grid-template-columns: 1fr; @@ -298,6 +340,8 @@ export function getTreeEditorHtml(webview: vscode.Webview): string {
+ +
+
+ + +
+
+ + + +

Add descriptions, drag items to change their order, or exclude them from the output. Descriptions are aligned as # comments in the preview.

@@ -329,6 +391,14 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { const statusElement = document.getElementById('status'); const respectGitignoreCheckbox = document.getElementById('respect-gitignore-checkbox'); const autoUpdateReadmeCheckbox = document.getElementById('auto-update-readme-checkbox'); + const markdownDiagnosticElement = document.getElementById('markdown-diagnostic'); + const markdownDiagnosticText = document.getElementById('markdown-diagnostic-text'); + const setupMarkdownButton = document.getElementById('setup-markdown-button'); + const readmePathInput = document.getElementById('readme-path-input'); + const outputStyleSelect = document.getElementById('output-style-select'); + const maxDepthInput = document.getElementById('max-depth-input'); + const undoButton = document.getElementById('undo-button'); + const redoButton = document.getElementById('redo-button'); const searchInput = document.getElementById('search-input'); const collapsedPaths = new Set(); let matchingPaths = new Set(); @@ -342,6 +412,20 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { vscode.postMessage({ type: 'copy' }); }); + undoButton.addEventListener('click', () => vscode.postMessage({ type: 'undo' })); + redoButton.addEventListener('click', () => vscode.postMessage({ type: 'redo' })); + + document.addEventListener('keydown', event => { + if (!(event.ctrlKey || event.metaKey) || event.key.toLocaleLowerCase() !== 'z') { + return; + } + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) { + return; + } + event.preventDefault(); + vscode.postMessage({ type: event.shiftKey ? 'redo' : 'undo' }); + }); + document.getElementById('reset-button').addEventListener('click', () => { setStatus('Resetting...'); vscode.postMessage({ type: 'reset' }); @@ -363,6 +447,30 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { }); }); + setupMarkdownButton.addEventListener('click', () => { + setStatus('Setting up Markdown...'); + vscode.postMessage({ type: 'setupMarkdown' }); + }); + + function updateOutputSettings() { + const maxDepth = Number(maxDepthInput.value); + if (!readmePathInput.value.trim() || !Number.isInteger(maxDepth) || maxDepth < -1) { + setStatus('Enter a Markdown target and a depth of -1 or greater.', true); + return; + } + setStatus('Updating output settings...'); + vscode.postMessage({ + type: 'setOutputSettings', + readmePath: readmePathInput.value, + outputStyle: outputStyleSelect.value, + maxDepth, + }); + } + + readmePathInput.addEventListener('change', updateOutputSettings); + outputStyleSelect.addEventListener('change', updateOutputSettings); + maxDepthInput.addEventListener('change', updateOutputSettings); + searchInput.addEventListener('input', renderTree); window.addEventListener('message', event => { @@ -373,6 +481,15 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { previewElement.textContent = message.treeString; respectGitignoreCheckbox.checked = Boolean(message.respectGitignore); autoUpdateReadmeCheckbox.checked = Boolean(message.autoUpdateReadme); + const diagnostic = message.readmeDiagnostic; + markdownDiagnosticText.textContent = diagnostic?.text ?? ''; + markdownDiagnosticElement.classList.toggle('error', Boolean(diagnostic?.isError)); + setupMarkdownButton.hidden = !diagnostic?.canSetup; + readmePathInput.value = message.readmePath ?? 'README.md'; + outputStyleSelect.value = message.outputStyle ?? 'unicode'; + maxDepthInput.value = String(message.maxDepth ?? -1); + undoButton.disabled = !message.canUndo; + redoButton.disabled = !message.canRedo; renderTree(); setStatus(message.status ?? ''); } else if (message.type === 'status') { @@ -633,6 +750,9 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { createBulkButton('Include all', node, false), createBulkButton('Exclude all', node, true), ); + if (!isRoot) { + actions.append(createDirectoryResetButton(node)); + } } return actions; } @@ -654,6 +774,22 @@ export function getTreeEditorHtml(webview: vscode.Webview): string { return button; } + function createDirectoryResetButton(node) { + const button = document.createElement('button'); + button.className = 'bulk-button'; + button.type = 'button'; + button.textContent = 'Reset'; + button.title = 'Reset this directory to its default order and metadata'; + button.addEventListener('click', event => { + event.stopPropagation(); + vscode.postMessage({ + type: 'resetDirectory', + directoryPath: node.path, + }); + }); + return button; + } + function createMoveButton(label, title, onClick) { const button = document.createElement('button'); button.className = 'move-button';