Skip to content

Commit f8fab51

Browse files
committed
Typeahead for folder path input
1 parent 311bb9a commit f8fab51

File tree

6 files changed

+567
-15
lines changed

6 files changed

+567
-15
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ jobs:
5050
run: make -C smoosense-py env
5151

5252
- name: Run tests
53+
env:
54+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
55+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
56+
AWS_REGION: ${{ secrets.AWS_REGION }}
5357
run: make -C smoosense-py test
5458

5559
py-integration-test:

smoosense-gui/src/components/home/HomeInfoSection.tsx

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,84 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useState, useEffect, useMemo, useRef } from 'react'
44
import { useRouter } from 'next/navigation'
55
import { Button } from '@/components/ui/button'
66
import { Input } from '@/components/ui/input'
7-
import { ExternalLink, Play, Download } from 'lucide-react'
7+
import { ExternalLink, Play, Download, AlertCircle } from 'lucide-react'
8+
import { API_PREFIX } from '@/lib/utils/urlUtils'
9+
import { debounce } from 'lodash'
10+
11+
type PathType = 's3' | 'local' | 'invalid' | 'empty'
12+
13+
function getPathType(path: string, isLocal: boolean): PathType {
14+
const trimmed = path.trim()
15+
if (!trimmed) return 'empty'
16+
// Match partial s3:// prefix as user is typing
17+
if (trimmed.startsWith('s3://') || 's3://'.startsWith(trimmed)) return 's3'
18+
if (trimmed.startsWith('/') || trimmed.startsWith('~')) {
19+
return isLocal ? 'local' : 'invalid'
20+
}
21+
return 'invalid'
22+
}
823

924
export default function HomeInfoSection() {
1025
const router = useRouter()
1126
const [folderPath, setFolderPath] = useState('')
27+
const [isLocal, setIsLocal] = useState(false)
28+
const [suggestions, setSuggestions] = useState<string[]>([])
29+
const [showSuggestions, setShowSuggestions] = useState(false)
30+
const [selectedIndex, setSelectedIndex] = useState(-1)
31+
const inputRef = useRef<HTMLInputElement>(null)
32+
const suggestionsRef = useRef<HTMLDivElement>(null)
33+
34+
useEffect(() => {
35+
const url = window.location.href
36+
setIsLocal(url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1'))
37+
}, [])
38+
39+
// Close suggestions when clicking outside
40+
useEffect(() => {
41+
const handleClickOutside = (e: MouseEvent) => {
42+
if (
43+
inputRef.current &&
44+
!inputRef.current.contains(e.target as Node) &&
45+
suggestionsRef.current &&
46+
!suggestionsRef.current.contains(e.target as Node)
47+
) {
48+
setShowSuggestions(false)
49+
}
50+
}
51+
document.addEventListener('mousedown', handleClickOutside)
52+
return () => document.removeEventListener('mousedown', handleClickOutside)
53+
}, [])
54+
55+
const fetchSuggestions = useMemo(
56+
() =>
57+
debounce(async (path: string, local: boolean) => {
58+
const pathType = getPathType(path, local)
59+
if (pathType === 'empty' || pathType === 'invalid') {
60+
setSuggestions([])
61+
return
62+
}
63+
64+
try {
65+
const endpoint = pathType === 's3'
66+
? `${API_PREFIX}/s3-typeahead`
67+
: `${API_PREFIX}/typeahead`
68+
69+
const response = await fetch(`${endpoint}?path=${encodeURIComponent(path)}`)
70+
if (response.ok) {
71+
const data = await response.json()
72+
setSuggestions(data)
73+
setShowSuggestions(data.length > 0)
74+
setSelectedIndex(-1)
75+
}
76+
} catch {
77+
setSuggestions([])
78+
}
79+
}, 300),
80+
[]
81+
)
1282

1383
const handleOpenUrl = (url: string) => {
1484
if (typeof window !== 'undefined') {
@@ -22,31 +92,102 @@ export default function HomeInfoSection() {
2292
}
2393
}
2494

25-
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
95+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
96+
const value = e.target.value
97+
setFolderPath(value)
98+
fetchSuggestions(value, isLocal)
99+
}
100+
101+
const handleSelectSuggestion = (suggestion: string) => {
102+
setFolderPath(suggestion)
103+
setShowSuggestions(false)
104+
setSuggestions([])
105+
// Fetch new suggestions for the selected path
106+
fetchSuggestions(suggestion, isLocal)
107+
inputRef.current?.focus()
108+
}
109+
110+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
26111
if (e.key === 'Enter') {
27-
handleGoToFolder()
112+
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
113+
handleSelectSuggestion(suggestions[selectedIndex])
114+
} else {
115+
handleGoToFolder()
116+
}
117+
} else if (e.key === 'ArrowDown') {
118+
e.preventDefault()
119+
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
120+
} else if (e.key === 'ArrowUp') {
121+
e.preventDefault()
122+
setSelectedIndex(prev => Math.max(prev - 1, -1))
123+
} else if (e.key === 'Escape') {
124+
setShowSuggestions(false)
125+
} else if (e.key === 'Tab' && showSuggestions && suggestions.length > 0) {
126+
e.preventDefault()
127+
const idx = selectedIndex >= 0 ? selectedIndex : 0
128+
handleSelectSuggestion(suggestions[idx])
28129
}
29130
}
30131

132+
const pathType = getPathType(folderPath, isLocal)
133+
const showError = pathType === 'invalid'
134+
31135
return (
32136
<div className="max-w-4xl w-full mb-12">
33137
<h2 className="text-xl font-semibold text-foreground mb-6">
34-
Browse local or s3 folders
138+
{isLocal ? 'Browse local or S3 folders' : 'Browse S3 folders'}
35139
</h2>
36140

37-
<div className="flex gap-2 mb-8">
38-
<Input
39-
type="text"
40-
placeholder="Enter folder path (e.g., /tmp/folder, ~/Downloads or s3://bucket/path)"
41-
value={folderPath}
42-
onChange={(e) => setFolderPath(e.target.value)}
43-
onKeyPress={handleKeyPress}
44-
className="flex-1"
45-
/>
46-
<Button onClick={handleGoToFolder} disabled={!folderPath.trim()}>
141+
<div className="flex gap-2 mb-2">
142+
<div className="relative flex-1">
143+
<Input
144+
ref={inputRef}
145+
type="text"
146+
placeholder={isLocal
147+
? "Enter folder path (e.g., /tmp/folder, ~/Downloads or s3://bucket/path)"
148+
: "Enter S3 path (e.g., s3://bucket/path)"
149+
}
150+
value={folderPath}
151+
onChange={handleInputChange}
152+
onKeyDown={handleKeyDown}
153+
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
154+
className={`font-mono ${showError ? 'border-red-500' : ''}`}
155+
/>
156+
{showSuggestions && suggestions.length > 0 && (
157+
<div
158+
ref={suggestionsRef}
159+
className="absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-auto"
160+
>
161+
{suggestions.map((suggestion, index) => (
162+
<div
163+
key={suggestion}
164+
className={`px-3 py-2 cursor-pointer text-sm font-mono truncate ${
165+
index === selectedIndex
166+
? 'bg-accent text-accent-foreground'
167+
: 'hover:bg-accent/50'
168+
}`}
169+
onClick={() => handleSelectSuggestion(suggestion)}
170+
>
171+
{suggestion}
172+
</div>
173+
))}
174+
</div>
175+
)}
176+
</div>
177+
<Button onClick={handleGoToFolder} disabled={!folderPath.trim() || showError}>
47178
Go
48179
</Button>
49180
</div>
181+
{showError && (
182+
<div className="flex items-center gap-2 text-red-500 text-sm mb-6">
183+
<AlertCircle className="h-4 w-4" />
184+
{isLocal
185+
? 'Path must start with /, ~, or s3://'
186+
: 'Path must start with s3:// (local paths not available on cloud)'
187+
}
188+
</div>
189+
)}
190+
{!showError && <div className="mb-6" />}
50191

51192
<h2 className="text-xl font-semibold text-foreground mb-6">
52193
Learn more

smoosense-py/smoosense/handlers/fs.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,66 @@ def generate() -> Generator[bytes, None, None]:
123123
return file_response
124124

125125

126+
@fs_bp.get("/typeahead")
127+
@requires_auth_api
128+
@handle_api_errors
129+
def typeahead() -> Response:
130+
"""
131+
Get typeahead suggestions for local file paths.
132+
Returns directories that match the given prefix.
133+
"""
134+
path = require_arg("path")
135+
136+
# Expand ~ to home directory
137+
if path.startswith("~"):
138+
expanded_path = os.path.expanduser(path)
139+
else:
140+
expanded_path = path
141+
142+
# Get the directory and prefix for matching
143+
if os.path.isdir(expanded_path):
144+
# Path is a directory, list its contents
145+
dir_path = expanded_path
146+
prefix = ""
147+
else:
148+
# Path is partial, get parent dir and filename prefix
149+
dir_path = os.path.dirname(expanded_path) or "/"
150+
prefix = os.path.basename(expanded_path).lower()
151+
152+
if not os.path.isdir(dir_path):
153+
return jsonify([])
154+
155+
suggestions = []
156+
try:
157+
for entry in os.scandir(dir_path):
158+
# Only suggest directories
159+
if not entry.is_dir():
160+
continue
161+
# Skip hidden directories
162+
if entry.name.startswith("."):
163+
continue
164+
# Match prefix (case-insensitive)
165+
if prefix and not entry.name.lower().startswith(prefix):
166+
continue
167+
168+
# Build the suggestion path
169+
if path.startswith("~"):
170+
# Keep ~ prefix in suggestion
171+
home = os.path.expanduser("~")
172+
suggestion = "~" + os.path.join(dir_path, entry.name)[len(home) :]
173+
else:
174+
suggestion = os.path.join(dir_path, entry.name)
175+
176+
suggestions.append(suggestion)
177+
178+
if len(suggestions) >= 10:
179+
break
180+
except PermissionError:
181+
pass
182+
183+
return jsonify(suggestions)
184+
185+
126186
@fs_bp.post("/upload")
127187
@requires_auth_api
128188
@handle_api_errors

smoosense-py/smoosense/handlers/s3.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,106 @@ def batch_proxy() -> Response:
4545
s3_fs = S3FileSystem(s3_client)
4646
signed = [s3_fs.sign_get_url(url) for url in urls]
4747
return jsonify(signed)
48+
49+
50+
@s3_bp.get("/s3-typeahead")
51+
@requires_auth_api
52+
@handle_api_errors
53+
def s3_typeahead() -> Response:
54+
"""
55+
Get typeahead suggestions for S3 paths.
56+
Returns prefixes (directories) that match the given path.
57+
"""
58+
from urllib.parse import urlparse
59+
60+
path: str = request.args.get("path", "") or ""
61+
62+
# Must start with s3:// or be a partial prefix of s3:// (including empty string)
63+
if path and not path.startswith("s3://") and not "s3://".startswith(path):
64+
return jsonify([])
65+
66+
s3_client = current_app.config["S3_CLIENT"]
67+
68+
# If path is partial prefix of s3://, list all buckets
69+
if not path.startswith("s3://"):
70+
try:
71+
response = s3_client.list_buckets()
72+
bucket_suggestions = [f"s3://{b['Name']}/" for b in response.get("Buckets", [])]
73+
return jsonify(bucket_suggestions[:10])
74+
except Exception as e:
75+
logger.warning(f"Failed to list buckets: {e}")
76+
return jsonify([])
77+
78+
# Parse the S3 URL
79+
parsed = urlparse(path)
80+
bucket: str = parsed.netloc
81+
key: str = str(parsed.path).lstrip("/")
82+
83+
# If no bucket specified (e.g., s3://), list all buckets
84+
if not bucket:
85+
try:
86+
response = s3_client.list_buckets()
87+
bucket_suggestions = [f"s3://{b['Name']}/" for b in response.get("Buckets", [])]
88+
return jsonify(bucket_suggestions[:10])
89+
except Exception as e:
90+
logger.warning(f"Failed to list buckets: {e}")
91+
return jsonify([])
92+
93+
# If no key specified and path doesn't end with /, match buckets by partial bucket name
94+
# If path ends with / (e.g., s3://bucket/), we should list bucket contents
95+
if not key and not path.endswith("/"):
96+
try:
97+
response = s3_client.list_buckets()
98+
bucket_lower = bucket.lower()
99+
bucket_suggestions = [
100+
f"s3://{b['Name']}/"
101+
for b in response.get("Buckets", [])
102+
if not bucket or b["Name"].lower().startswith(bucket_lower)
103+
]
104+
return jsonify(bucket_suggestions[:10])
105+
except Exception as e:
106+
logger.warning(f"Failed to list buckets: {e}")
107+
return jsonify([])
108+
109+
# Get the directory prefix and search prefix
110+
dir_prefix: str
111+
search_prefix: str
112+
if key.endswith("/") or not key:
113+
# Path ends with / or is just bucket, list contents
114+
dir_prefix = key
115+
search_prefix = ""
116+
else:
117+
# Path is partial, get parent dir and filename prefix
118+
parts = key.rsplit("/", 1)
119+
if len(parts) == 2:
120+
dir_prefix = parts[0] + "/"
121+
search_prefix = parts[1].lower()
122+
else:
123+
dir_prefix = ""
124+
search_prefix = parts[0].lower()
125+
126+
suggestions: list[str] = []
127+
try:
128+
paginator = s3_client.get_paginator("list_objects_v2")
129+
for page in paginator.paginate(Bucket=bucket, Prefix=dir_prefix, Delimiter="/"):
130+
# Add common prefixes (directories)
131+
for prefix_entry in page.get("CommonPrefixes", []):
132+
prefix_path: str = prefix_entry["Prefix"]
133+
name = prefix_path[len(dir_prefix) :].rstrip("/")
134+
135+
# Match search prefix (case-insensitive)
136+
if search_prefix and not name.lower().startswith(search_prefix):
137+
continue
138+
139+
suggestion = f"s3://{bucket}/{prefix_path}"
140+
suggestions.append(suggestion)
141+
142+
if len(suggestions) >= 10:
143+
break
144+
145+
if len(suggestions) >= 10:
146+
break
147+
except Exception as e:
148+
logger.warning(f"Failed to list S3 prefixes: {e}")
149+
150+
return jsonify(suggestions)

0 commit comments

Comments
 (0)