Merge pull request #635 from erlebach/fix/ls-absolute-path-collections

fix(ls): handle collections whose names are absolute paths
This commit is contained in:
Tobias Lütke 2026-05-16 13:13:15 -04:00 committed by GitHub
commit d0bcdf0cfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 107 additions and 3 deletions

View File

@ -1292,8 +1292,33 @@ function listFiles(pathArg?: string): void {
let collectionName: string;
let pathPrefix: string | null = null;
if (pathArg.startsWith('qmd://')) {
// Virtual path format: qmd://collection/path
const afterScheme = pathArg.startsWith('qmd://') ? pathArg.slice('qmd://'.length) : null;
if (afterScheme !== null && afterScheme.startsWith('/')) {
// Absolute-path collection: qmd:///Users/foo/bar — normalizeVirtualPath would corrupt
// this by stripping all leading slashes, so bypass parseVirtualPath entirely.
const normalized = afterScheme.replace(/\/$/, '');
const allColls = yamlListCollections();
const match = allColls
.filter(c => normalized === c.name || normalized.startsWith(c.name + '/'))
.sort((a, b) => b.name.length - a.name.length)[0];
if (match) {
collectionName = match.name;
const rest = normalized.slice(match.name.length).replace(/^\//, '');
pathPrefix = rest || null;
} else {
// Preserve the historical qmd:////collection/path alias behavior for normal
// collections when no absolute-path collection matches.
const parsed = parseVirtualPath(pathArg);
if (!parsed) {
console.error(`Invalid virtual path: ${pathArg}`);
closeDb();
process.exit(1);
}
collectionName = parsed.collectionName;
pathPrefix = parsed.path;
}
} else if (afterScheme !== null) {
// Normal virtual path: qmd://collection-name/path
const parsed = parseVirtualPath(pathArg);
if (!parsed) {
console.error(`Invalid virtual path: ${pathArg}`);
@ -1302,8 +1327,22 @@ function listFiles(pathArg?: string): void {
}
collectionName = parsed.collectionName;
pathPrefix = parsed.path;
} else if (pathArg.startsWith('/')) {
// Raw absolute filesystem path — longest-prefix match against collection names
const normalized = pathArg.replace(/\/$/, '');
const allColls = yamlListCollections();
const match = allColls
.filter(c => normalized === c.name || normalized.startsWith(c.name + '/'))
.sort((a, b) => b.name.length - a.name.length)[0];
if (match) {
collectionName = match.name;
const rest = normalized.slice(match.name.length).replace(/^\//, '');
pathPrefix = rest || null;
} else {
collectionName = normalized;
}
} else {
// Just collection name or collection/path
// Short collection name or name/path
const parts = pathArg.split('/');
collectionName = parts[0] || '';
if (parts.length > 1) {

View File

@ -888,6 +888,71 @@ describe("CLI ls Command", () => {
expect(stdout).toContain("qmd://fixtures/docs/api.md");
});
test("continues to normalize extra slashes for normal collection virtual paths", async () => {
const { stdout, stderr, exitCode } = await runQmd(["ls", "qmd:///fixtures/docs"], { dbPath: localDbPath });
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(stdout).toContain("qmd://fixtures/docs/api.md");
});
test("lists an absolute-path collection from a qmd:/// virtual path", async () => {
const env = await createIsolatedTestEnv("absolute-qmd-path");
const absoluteDir = await mkdtemp(join(tmpdir(), "qmd-absolute-collection-"));
await writeFile(join(absoluteDir, "root.md"), "# Absolute collection\n");
await writeFile(
join(env.configDir, "index.yml"),
`collections:\n "${absoluteDir}":\n path: "${absoluteDir}"\n pattern: "**/*.md"\n`
);
const update = await runQmd(["update"], {
cwd: absoluteDir,
dbPath: env.dbPath,
configDir: env.configDir,
});
expect(update.exitCode).toBe(0);
const { stdout, stderr, exitCode } = await runQmd(["ls", `qmd://${absoluteDir}/`], {
cwd: absoluteDir,
dbPath: env.dbPath,
configDir: env.configDir,
});
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(stdout).toContain(`qmd://${absoluteDir}/root.md`);
});
test("lists an absolute-path collection from a raw path using the longest prefix match", async () => {
const env = await createIsolatedTestEnv("absolute-raw-path");
const parentCollectionName = await mkdtemp(join(tmpdir(), "qmd-absolute-parent-name-"));
const childCollectionName = join(parentCollectionName, "nested");
const parentDataDir = await mkdtemp(join(tmpdir(), "qmd-absolute-parent-data-"));
const childDataDir = await mkdtemp(join(tmpdir(), "qmd-absolute-child-data-"));
await writeFile(join(parentDataDir, "parent.md"), "# Parent collection\n");
await writeFile(join(childDataDir, "child.md"), "# Child collection\n");
await writeFile(
join(env.configDir, "index.yml"),
`collections:\n "${parentCollectionName}":\n path: "${parentDataDir}"\n pattern: "**/*.md"\n "${childCollectionName}":\n path: "${childDataDir}"\n pattern: "**/*.md"\n`
);
const update = await runQmd(["update"], {
cwd: parentDataDir,
dbPath: env.dbPath,
configDir: env.configDir,
});
expect(update.exitCode).toBe(0);
const { stdout, stderr, exitCode } = await runQmd(["ls", `${childCollectionName}/`], {
cwd: childDataDir,
dbPath: env.dbPath,
configDir: env.configDir,
});
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(stdout).toContain(`qmd://${childCollectionName}/child.md`);
expect(stdout).not.toContain("No files found");
expect(stdout).not.toContain(`qmd://${parentCollectionName}/parent.md`);
});
test("handles non-existent collection", async () => {
const { stderr, exitCode } = await runQmd(["ls", "nonexistent"], { dbPath: localDbPath });
expect(exitCode).toBe(1);