diff --git a/apps/staged/src/lib/features/projects/SubpathInput.svelte b/apps/staged/src/lib/features/projects/SubpathInput.svelte index e37c3b75..66c9f37c 100644 --- a/apps/staged/src/lib/features/projects/SubpathInput.svelte +++ b/apps/staged/src/lib/features/projects/SubpathInput.svelte @@ -20,6 +20,12 @@ import FolderOpen from '@lucide/svelte/icons/folder-open'; import * as commands from '../../api/commands'; import { Input } from '$lib/components/ui/input'; + import { + getSubpathCurrentSegment, + getSubpathParentPath, + isSubpathSuggestionVisible, + normalizeSubpathInput, + } from './subpathSuggestions'; interface Props { value: string; @@ -47,33 +53,12 @@ }; }); - function normalize(val: string): string { - return val.trim().replace(/^\/+|\/+$/g, ''); - } - function validationErrorMessage(error: unknown): string { const message = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error); return message.replace(/^git command failed:\s*/i, '') || 'Invalid path in repo'; } - // Split the value into parent path and current segment for suggestions. - // For "apps/on" → parent="apps", segment="on" - // For "apps" → parent="", segment="apps" - function getParentPath(val: string): string { - const trimmed = val.trim().replace(/^\/+/, ''); - const lastSlash = trimmed.lastIndexOf('/'); - if (lastSlash === -1) return ''; - return trimmed.substring(0, lastSlash); - } - - function getCurrentSegment(val: string): string { - const trimmed = val.trim().replace(/^\/+/, ''); - const lastSlash = trimmed.lastIndexOf('/'); - if (lastSlash === -1) return trimmed; - return trimmed.substring(lastSlash + 1); - } - /** * Fetch suggestions for the current input value. * @@ -93,8 +78,8 @@ return; } - const parentPath = getParentPath(val); - const segment = getCurrentSegment(val).toLowerCase(); + const parentPath = getSubpathParentPath(val); + const segment = getSubpathCurrentSegment(val).toLowerCase(); try { // 1. List directories at the parent level @@ -132,7 +117,7 @@ * subpath. Called by the parent on submit. */ async function waitForValidation(): Promise { - const trimmed = normalize(value); + const trimmed = normalizeSubpathInput(value); if (!trimmed) { return { valid: true }; } @@ -198,10 +183,10 @@ } // Filter suggestions: match full paths that start with the normalized input + // and hide dot-prefixed directory segments until the user types "." in + // that path segment. let filteredSuggestions = $derived.by(() => { - const trimmed = normalize(value).toLowerCase(); - if (!trimmed) return suggestions; - return suggestions.filter((s) => s.toLowerCase().startsWith(trimmed)); + return suggestions.filter((s) => isSubpathSuggestionVisible(s, value)); }); // Re-fetch suggestions when repo changes diff --git a/apps/staged/src/lib/features/projects/subpathSuggestions.test.ts b/apps/staged/src/lib/features/projects/subpathSuggestions.test.ts new file mode 100644 index 00000000..46715340 --- /dev/null +++ b/apps/staged/src/lib/features/projects/subpathSuggestions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { isSubpathSuggestionVisible } from './subpathSuggestions'; + +describe('isSubpathSuggestionVisible', () => { + it('hides root dot directories until the user types a dot in the root segment', () => { + expect(isSubpathSuggestionVisible('.github', '')).toBe(false); + expect(isSubpathSuggestionVisible('.github', '.')).toBe(true); + expect(isSubpathSuggestionVisible('.github', '.g')).toBe(true); + }); + + it('hides dot directories in child segments until that segment includes a dot', () => { + expect(isSubpathSuggestionVisible('apps/.config', 'apps')).toBe(false); + expect(isSubpathSuggestionVisible('apps/.config', 'apps/')).toBe(false); + expect(isSubpathSuggestionVisible('apps/.config', 'apps/.')).toBe(true); + expect(isSubpathSuggestionVisible('apps/.config', 'apps/.c')).toBe(true); + }); + + it('keeps descendants visible when an already typed parent segment starts with a dot', () => { + expect(isSubpathSuggestionVisible('.github/workflows', '.github')).toBe(true); + expect(isSubpathSuggestionVisible('.github/.actions', '.github')).toBe(false); + expect(isSubpathSuggestionVisible('.github/.actions', '.github/.')).toBe(true); + }); + + it('still requires suggestions to match the typed prefix', () => { + expect(isSubpathSuggestionVisible('packages/app', 'apps')).toBe(false); + expect(isSubpathSuggestionVisible('apps/web', 'APP')).toBe(true); + }); +}); diff --git a/apps/staged/src/lib/features/projects/subpathSuggestions.ts b/apps/staged/src/lib/features/projects/subpathSuggestions.ts new file mode 100644 index 00000000..86bcbd79 --- /dev/null +++ b/apps/staged/src/lib/features/projects/subpathSuggestions.ts @@ -0,0 +1,37 @@ +export function normalizeSubpathInput(value: string): string { + return value.trim().replace(/^\/+|\/+$/g, ''); +} + +// Split the value into parent path and current segment for suggestions. +// For "apps/on" -> parent="apps", segment="on" +// For "apps" -> parent="", segment="apps" +export function getSubpathParentPath(value: string): string { + const trimmed = value.trim().replace(/^\/+/, ''); + const lastSlash = trimmed.lastIndexOf('/'); + if (lastSlash === -1) return ''; + return trimmed.substring(0, lastSlash); +} + +export function getSubpathCurrentSegment(value: string): string { + const trimmed = value.trim().replace(/^\/+/, ''); + const lastSlash = trimmed.lastIndexOf('/'); + if (lastSlash === -1) return trimmed; + return trimmed.substring(lastSlash + 1); +} + +export function isSubpathSuggestionVisible(suggestion: string, input: string): boolean { + const normalizedInput = normalizeSubpathInput(input); + const normalizedSuggestion = normalizeSubpathInput(suggestion); + const lowerInput = normalizedInput.toLowerCase(); + + if (lowerInput && !normalizedSuggestion.toLowerCase().startsWith(lowerInput)) { + return false; + } + + const inputSegments = normalizedInput.split('/'); + + return normalizedSuggestion.split('/').every((segment, index) => { + if (!segment.startsWith('.')) return true; + return inputSegments[index]?.includes('.') ?? false; + }); +}