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
21 changes: 14 additions & 7 deletions index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import inquirer from 'inquirer';
import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
import { execSync } from 'child_process';
import ora from 'ora';
import { expandHomeDir, isGitRepo, validateMaxDepth, getExecuteCommand, validateConfig } from './src/utils.mjs';
import { expandHomeDir, isGitRepo, validateMaxDepth, getExecuteCommand, validateConfig, getReadmePreview } from './src/utils.mjs';
import { RepoCache } from './src/cache.mjs';

// Register the autocomplete prompt
Expand All @@ -27,7 +27,7 @@ Usage: lcode [path] [maxDepth] [command]

Arguments:
path Starting directory to search (default: current directory)
maxDepth Maximum search depth 1-10 (default: 3)
maxDepth Maximum search depth 1-10 (default: 5)
command Command to execute in selected repo (default: "code .")

Options:
Expand Down Expand Up @@ -55,6 +55,7 @@ if (process.argv.includes('--init')) {
execute: 'code .',
execute2: 'zsh',
execute3: '[ -f .nvmrc ] && . ~/.nvm/nvm.sh && nvm use; code .',
previewLength: 80
};
try {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
Expand Down Expand Up @@ -195,7 +196,9 @@ const main = async () => {
if (process.argv.includes('--list')) {
gitRepos.forEach((repo, index) => {
const relativePath = path.relative(BASE_DIR, repo) || path.basename(repo);
console.log(`${index}: ${relativePath}`);
const preview = getReadmePreview(repo, config.previewLength || 80);
const display = preview ? `${relativePath} - ${preview}` : relativePath;
console.log(`${index}: ${display}`);
});
return;
}
Expand All @@ -221,10 +224,14 @@ const main = async () => {
}

// Interactive mode
const choices = gitRepos.map((repo) => ({
name: path.relative(BASE_DIR, repo) || path.basename(repo),
value: repo,
}));
const choices = gitRepos.map((repo) => {
const name = path.relative(BASE_DIR, repo) || path.basename(repo);
const preview = getReadmePreview(repo, config.previewLength || 80);
return {
name: preview ? `${name} - ${preview}` : name,
value: repo,
};
});

const answer = await inquirer.prompt([
{
Expand Down
47 changes: 46 additions & 1 deletion src/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const isGitRepoAsync = async (folderPath) => {
};

// Validate and sanitize maxDepth
export const validateMaxDepth = (depth, defaultDepth = 3) => {
export const validateMaxDepth = (depth, defaultDepth = 5) => {
const parsed = parseInt(depth, 10);
if (isNaN(parsed)) return defaultDepth;
return Math.max(1, Math.min(parsed, 10));
Expand All @@ -40,6 +40,51 @@ export const getExecuteCommand = (args, config) => {
return commands[0] || 'code .';
};

// Get README preview for repository
export const getReadmePreview = (repoPath, maxLength = 80) => {
const readmeFiles = ['README.md', 'readme.md', 'README.txt', 'readme.txt'];

for (const filename of readmeFiles) {
const readmePath = path.join(repoPath, filename);
try {
if (fs.existsSync(readmePath)) {
const content = fs.readFileSync(readmePath, 'utf8');
const lines = content.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.filter(line => !line.startsWith('<!--')) // Skip HTML comments
.filter(line => !line.startsWith('[![')) // Skip badge lines
.filter(line => !line.startsWith('<')) // Skip HTML tags
.filter(line => !line.match(/^#+\s*$/)); // Skip empty headers

if (lines.length === 0) continue;

// Get first meaningful line, remove markdown headers
let firstLine = lines[0].replace(/^#+\s*/, '').trim();
const repoName = path.basename(repoPath);

// Skip if it's just the project name (same as repo name), try next line
if (firstLine.toLowerCase() === repoName.toLowerCase() && lines.length > 1) {
firstLine = lines[1].replace(/^#+\s*/, '').trim();
}

if (!firstLine) continue;

// Strip markdown links: [text](url) -> text
firstLine = firstLine.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');

// Strip non-alphanumeric chars except spaces, dots, hyphens
firstLine = firstLine.replace(/[^a-zA-Z0-9\s.-]/g, '');

return firstLine.length > maxLength ? firstLine.substring(0, maxLength - 3) + '...' : firstLine;
}
} catch {
// Ignore errors and continue
}
}
return null;
};

// Validate config file structure
export const validateConfig = (config) => {
const errors = [];
Expand Down
96 changes: 94 additions & 2 deletions test/utils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert';
import path from 'path';
import fs from 'fs';
import { expandHomeDir, isGitRepo, validateMaxDepth, getExecuteCommand, validateConfig } from '../src/utils.mjs';
import { expandHomeDir, isGitRepo, validateMaxDepth, getExecuteCommand, validateConfig, getReadmePreview } from '../src/utils.mjs';

test('expandHomeDir - expands ~ correctly', () => {
const result = expandHomeDir('~/test');
Expand All @@ -24,7 +24,7 @@ test('validateMaxDepth - validates numeric input', () => {
assert.strictEqual(validateMaxDepth('5'), 5);
assert.strictEqual(validateMaxDepth('0'), 1); // minimum is 1
assert.strictEqual(validateMaxDepth('15'), 10); // maximum is 10
assert.strictEqual(validateMaxDepth('invalid'), 3); // default
assert.strictEqual(validateMaxDepth('invalid'), 5); // default
assert.strictEqual(validateMaxDepth(null, 7), 7); // custom default
});

Expand Down Expand Up @@ -102,3 +102,95 @@ test('isGitRepo - returns false for non-git directory', () => {
// Cleanup
fs.rmSync(tempDir, { recursive: true });
});
test('getReadmePreview - extracts first meaningful line', () => {
const tempDir = path.join(process.cwd(), 'temp-readme-test');
const readmePath = path.join(tempDir, 'README.md');

try {
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(readmePath, '# temp-readme-test\n\nA cool project that does amazing things.\n\n## Installation\n...');

const preview = getReadmePreview(tempDir);
assert.strictEqual(preview, 'A cool project that does amazing things.');
} finally {
if (fs.existsSync(readmePath)) fs.unlinkSync(readmePath);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});

test('getReadmePreview - handles missing README', () => {
const tempDir = path.join(process.cwd(), 'temp-no-readme');

try {
fs.mkdirSync(tempDir, { recursive: true });
const preview = getReadmePreview(tempDir);
assert.strictEqual(preview, null);
} finally {
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});

test('getReadmePreview - truncates long descriptions', () => {
const tempDir = path.join(process.cwd(), 'temp-long-readme');
const readmePath = path.join(tempDir, 'README.md');
const longText = 'A'.repeat(100);

try {
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(readmePath, `${longText}`);

const preview = getReadmePreview(tempDir);
assert(preview.endsWith('...'));
assert(preview.length <= 80);
} finally {
if (fs.existsSync(readmePath)) fs.unlinkSync(readmePath);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});
test('getReadmePreview - strips markdown links', () => {
const tempDir = path.join(process.cwd(), 'temp-link-readme');
const readmePath = path.join(tempDir, 'README.md');

try {
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(readmePath, 'A tool for the [TODO.md](https://github.com/todo-md/todo-md) standard');

const preview = getReadmePreview(tempDir);
assert.strictEqual(preview, 'A tool for the TODO.md standard');
} finally {
if (fs.existsSync(readmePath)) fs.unlinkSync(readmePath);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});
test('getReadmePreview - strips non-alphanumeric characters', () => {
const tempDir = path.join(process.cwd(), 'temp-chars-readme');
const readmePath = path.join(tempDir, 'README.md');

try {
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(readmePath, 'A tool with @#$%^&*()+={}[]|\\:";\'<>?,/ special chars!');

const preview = getReadmePreview(tempDir);
assert.strictEqual(preview, 'A tool with special chars');
} finally {
if (fs.existsSync(readmePath)) fs.unlinkSync(readmePath);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});

test('getReadmePreview - respects custom maxLength', () => {
const tempDir = path.join(process.cwd(), 'temp-length-readme');
const readmePath = path.join(tempDir, 'README.md');

try {
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(readmePath, 'This is a very long description that should be truncated');

const preview = getReadmePreview(tempDir, 20);
assert(preview.length <= 20);
assert(preview.endsWith('...'));
} finally {
if (fs.existsSync(readmePath)) fs.unlinkSync(readmePath);
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
}
});