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
79 changes: 70 additions & 9 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,42 @@ interface DialogSelectDirectoryProps {
onSelect: (result: string | string[] | null) => void
}

/**
* Check if a path is absolute (Windows drive letter or Unix root)
* Handles: C:\, D:\, /home, /c/, etc.
*/
function isAbsolutePath(path: string): boolean {
if (!path) return false
// Windows drive letter (C:\, D:/, etc.)
if (/^[a-zA-Z]:[\/]/.test(path)) return true
// Unix absolute path or Git Bash style (/c/, /d/, /home, etc.)
if (path.startsWith("/")) return true
return false
}

/**
* Extract the search directory from an absolute path query
* Returns the directory portion to search within
*/
function getSearchRoot(query: string): string | null {
if (!isAbsolutePath(query)) return null

// For Windows paths like "C:\Users" or "D:\Projects\foo"
// Return the path up to the last separator for searching
const normalized = query.replace(/\/g, "/")
const lastSlash = normalized.lastIndexOf("/")

if (lastSlash <= 0) {
// Root of drive: "C:\" -> "C:/"
if (/^[a-zA-Z]:/.test(query)) {
return query.slice(0, 2) + "/"
}
return "/"
}

return normalized.slice(0, lastSlash) || "/"
}

export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
Expand All @@ -22,8 +58,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const root = createMemo(() => sync.data.path.home || sync.data.path.directory)

function join(base: string | undefined, rel: string) {
const b = (base ?? "").replace(/[\\/]+$/, "")
const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
const b = (base ?? "").replace(/[\/]+$/, "")
const r = rel.replace(/^[\/]+/, "").replace(/[\/]+$/, "")
if (!b) return r
if (!r) return b
return b + "/" + r
Expand All @@ -46,27 +82,51 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
if (!query) return query
if (query.startsWith("~/")) return query.slice(2)

// If it's an absolute path, don't normalize - return as-is for absolute search
if (isAbsolutePath(query)) return query

if (h) {
const lc = query.toLowerCase()
const hc = h.toLowerCase()
if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
return query.slice(h.length).replace(/^[\\/]+/, "")
return query.slice(h.length).replace(/^[\/]+/, "")
}
}

return query
}

async function fetchDirs(query: string) {
const directory = root()
// Check if query is an absolute path (Windows or Unix)
let directory: string
let searchQuery: string

if (isAbsolutePath(query)) {
// For absolute paths, search from the path's parent directory
const searchRoot = getSearchRoot(query)
if (searchRoot) {
directory = searchRoot
// Extract just the filename/folder portion for the query
const normalized = query.replace(/\/g, "/")
const lastSlash = normalized.lastIndexOf("/")
searchQuery = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : query
} else {
directory = root() || ""
searchQuery = query
}
} else {
directory = root() || ""
searchQuery = query
}

if (!directory) return [] as string[]

const results = await sdk.client.find
.files({ directory, query, type: "directory", limit: 50 })
.files({ directory, query: searchQuery, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])

return results.map((x) => x.replace(/[\\/]+$/, ""))
return results.map((x) => x.replace(/[\/]+$/, ""))
}

const directories = async (filter: string) => {
Expand All @@ -75,15 +135,16 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}

function resolve(rel: string) {
const absolute = join(root(), rel)
// If the result is already an absolute path, use it directly
const absolute = isAbsolutePath(rel) ? rel : join(root(), rel)
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}

return (
<Dialog title={props.title ?? "Open project"}>
<List
search={{ placeholder: "Search folders", autofocus: true }}
search={{ placeholder: "Search folders (or type absolute path like D:\)", autofocus: true }}
emptyMessage="No folders found"
items={directories}
key={(x) => x}
Expand All @@ -93,7 +154,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}}
>
{(rel) => {
const path = display(rel)
const path = isAbsolutePath(rel) ? rel : display(rel)
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
Expand Down
35 changes: 30 additions & 5 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ function projectsKey(url: string) {
return url
}

/**
* Check if the current platform is Windows.
* Windows paths are case-insensitive, so we need special handling.
*/
function isWindows(): boolean {
return typeof navigator !== "undefined" && navigator.platform?.toLowerCase().includes("win")
}

/**
* Compare two file paths for equality.
* On Windows, paths are compared case-insensitively.
* On other platforms, paths are compared case-sensitively.
*/
function pathsEqual(path1: string, path2: string): boolean {
if (isWindows()) {
return path1.toLowerCase() === path2.toLowerCase()
}
return path1 === path2
}

export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultUrl: string }) => {
Expand Down Expand Up @@ -141,7 +161,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
// Use case-insensitive comparison on Windows to prevent duplicate entries
if (current.find((x) => pathsEqual(x.worktree, directory))) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
Expand All @@ -151,28 +172,32 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
// Use case-insensitive comparison on Windows
current.filter((x) => !pathsEqual(x.worktree, directory)),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
// Use case-insensitive comparison on Windows
const index = current.findIndex((x) => pathsEqual(x.worktree, directory))
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
// Use case-insensitive comparison on Windows
const index = current.findIndex((x) => pathsEqual(x.worktree, directory))
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
// Use case-insensitive comparison on Windows
const fromIndex = current.findIndex((x) => pathsEqual(x.worktree, directory))
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
Expand Down