Skip to content
Open
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
38 changes: 38 additions & 0 deletions packages/happy-app/sources/app/(app)/new/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { isMachineOnline } from '@/utils/machineUtils';
import { machineSpawnNewSession } from '@/sync/ops';
import { createWorktree, listWorktrees } from '@/utils/worktree';
import { resolveAbsolutePath } from '@/utils/pathUtils';
import { useDirSuggestions } from '@/hooks/useDirSuggestions';
import { formatPathRelativeToHome, formatLastSeen } from '@/utils/sessionUtils';
import { useNavigateToSession } from '@/hooks/useNavigateToSession';
import { useNewSessionDraft } from '@/hooks/useNewSessionDraft';
Expand Down Expand Up @@ -293,13 +294,15 @@ function PathPickerContent({
items,
value,
homeDir,
machineId,
onChangeValue,
onDone,
}: {
title: string;
items: PickerItem[];
value: string | null;
homeDir?: string;
machineId?: string | null;
onChangeValue: (value: string) => void;
onDone?: () => void;
}) {
Expand All @@ -308,6 +311,8 @@ function PathPickerContent({
const currentValue = value ?? '';
const [selection, setSelection] = React.useState<{ start: number; end: number } | undefined>(undefined);

const dirSuggestions = useDirSuggestions(machineId, currentValue);

React.useEffect(() => {
const timeout = setTimeout(() => {
inputRef.current?.focus();
Expand Down Expand Up @@ -416,6 +421,37 @@ function PathPickerContent({
</Text>
)}

{dirSuggestions.length > 0 && (
<>
<Text style={[pickerStyles.sectionLabel, { color: theme.colors.textSecondary }]}>
Suggestions
</Text>
<ScrollView style={pickerStyles.optionList} keyboardShouldPersistTaps="handled">
{dirSuggestions.map((suggestion) => (
<Pressable
key={suggestion.fullPath}
style={(p) => [pickerStyles.option, p.pressed && pickerStyles.optionPressed]}
onPress={() => {
const nextValue = suggestion.fullPath + '/';
onChangeValue(nextValue);
setSelection({ start: nextValue.length, end: nextValue.length });
setTimeout(() => inputRef.current?.focus(), 0);
}}
>
<Ionicons
name="folder-outline"
size={16}
color={theme.colors.textSecondary}
/>
<Text style={[pickerStyles.optionText, { color: theme.colors.text }]}>
{suggestion.fullPath}
</Text>
</Pressable>
))}
</ScrollView>
</>
)}

<Text style={[pickerStyles.sectionLabel, { color: theme.colors.textSecondary }]}>
Recent
</Text>
Expand Down Expand Up @@ -1140,6 +1176,7 @@ function NewSessionScreen() {
items={pathItems}
value={selectedPath}
homeDir={selectedHomeDir}
machineId={selectedMachineId}
onChangeValue={setSelectedPath}
onDone={() => setActivePicker(null)}
/>
Expand Down Expand Up @@ -1227,6 +1264,7 @@ function NewSessionScreen() {
items={pathItems}
value={selectedPath}
homeDir={selectedHomeDir}
machineId={selectedMachineId}
onChangeValue={setSelectedPath}
onDone={() => setActivePicker(null)}
/>
Expand Down
134 changes: 134 additions & 0 deletions packages/happy-app/sources/hooks/useDirSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Provides filesystem directory suggestions for a path input field.
*
* Given a machine ID and a partially-typed path, lists subdirectories under the
* parent directory and filters them by the typed prefix. Results are debounced
* and cached per (machineId, parentDir) pair so rapid keystrokes don't flood
* the machine with bash calls.
*/

import * as React from 'react';
import { machineBash } from '@/sync/ops';

const DEBOUNCE_MS = 250;
const CACHE_TTL_MS = 10_000;

interface CacheEntry {
dirs: string[];
ts: number;
}

// Module-level cache — survives re-renders, cleared when TTL expires
const dirCache = new Map<string, CacheEntry>();

function cacheKey(machineId: string, dir: string): string {
return `${machineId}:${dir}`;
}

async function fetchDirs(machineId: string, dir: string): Promise<string[]> {
const key = cacheKey(machineId, dir);
const cached = dirCache.get(key);
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.dirs;
}

// List only directories (trailing slash marker from ls -p)
const result = await machineBash(
machineId,
`ls -1ap "${dir}" 2>/dev/null | grep '/$' | grep -v '^\\.\\.\\?/$'`,
dir,
);

if (!result.success) {
return [];
}

const dirs = result.stdout
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => s.replace(/\/$/, '')); // strip trailing slash

dirCache.set(key, { dirs, ts: Date.now() });
return dirs;
}

export interface DirSuggestion {
/** Full absolute path of the suggested directory */
fullPath: string;
/** Display label (just the directory name) */
label: string;
}

/**
* Returns directory suggestions for `pathText` typed by the user.
*
* Returns empty array when:
* - `machineId` is null/undefined (machine not selected)
* - `pathText` is empty
* - The last path segment contains no typed characters after the final `/`
* AND the path already ends with `/` (to avoid suggestions on bare `/`)
*/
export function useDirSuggestions(
machineId: string | null | undefined,
pathText: string,
): DirSuggestion[] {
const [suggestions, setSuggestions] = React.useState<DirSuggestion[]>([]);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const latestRef = React.useRef({ machineId, pathText });

React.useEffect(() => {
latestRef.current = { machineId, pathText };
});

React.useEffect(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}

if (!machineId || !pathText) {
setSuggestions([]);
return;
}

timerRef.current = setTimeout(async () => {
const { machineId: mid, pathText: pt } = latestRef.current;
if (!mid || !pt) return;

// Split into parent directory + prefix
const lastSlash = pt.lastIndexOf('/');
const parentDir = lastSlash >= 0 ? pt.slice(0, lastSlash + 1) : '/';
const prefix = lastSlash >= 0 ? pt.slice(lastSlash + 1) : pt;

// Don't suggest if there's nothing typed after the last slash
// (avoids a suggestions list appearing on a bare path like "/home/user/")
if (prefix.length === 0) {
setSuggestions([]);
return;
}

const dirs = await fetchDirs(mid, parentDir || '/');

if (latestRef.current.machineId !== mid || latestRef.current.pathText !== pt) {
return; // stale
}

const filtered = dirs
.filter((d) => d.toLowerCase().startsWith(prefix.toLowerCase()))
.map((d) => ({
fullPath: `${parentDir}${d}`,
label: d,
}));

setSuggestions(filtered);
}, DEBOUNCE_MS);

return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, [machineId, pathText]);

return suggestions;
}
Loading