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
39 changes: 12 additions & 27 deletions apps/staged/src/lib/features/projects/SubpathInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -132,7 +117,7 @@
* subpath. Called by the parent on submit.
*/
async function waitForValidation(): Promise<SubpathValidationResult> {
const trimmed = normalize(value);
const trimmed = normalizeSubpathInput(value);
if (!trimmed) {
return { valid: true };
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 37 additions & 0 deletions apps/staged/src/lib/features/projects/subpathSuggestions.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}