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
24 changes: 23 additions & 1 deletion crates/github/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,31 @@ pub fn github_check_cli_status(app: AppHandle) -> Result<GitHubCliStatus, String
.output()
.map_err(|e| format!("Failed to execute gh command: {}", e))?;

// gh auth status reports results on stderr. On some versions, exit code 0
// is returned even when the token is expired or invalid (see cli/cli#8845).
// Parse stderr to look for authentication failure indicators instead of
// relying solely on the exit code.
let stderr = String::from_utf8_lossy(&output.stderr);

if output.status.success() {
Ok(GitHubCliStatus::Authenticated)
// Exit code 0, but verify the output actually confirms authentication.
// A successful auth status contains "Logged in to" on stderr.
if stderr.contains("Logged in to") {
Ok(GitHubCliStatus::Authenticated)
} else if stderr.contains("not logged in")
|| stderr.contains("no authentications")
|| stderr.contains("token is invalid")
|| stderr.contains("Failed to log in")
|| (stderr.contains("The token") && stderr.contains("is invalid"))
{
// Token exists but is invalid/expired — gh still exits 0 in some versions
Ok(GitHubCliStatus::NotAuthenticated)
} else {
// Exit code 0 with no recognizable failure — assume authenticated
Ok(GitHubCliStatus::Authenticated)
}
} else {
// Non-zero exit code always means not authenticated
Ok(GitHubCliStatus::NotAuthenticated)
}
}
Expand Down
44 changes: 13 additions & 31 deletions src/features/file-system/controllers/file-operations.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import type { FileEntry } from "../types/app";
import {
getSymlinkInfo,
createDirectory as platformCreateDirectory,
deletePath as platformDeletePath,
readDirectory as platformReadDirectory,
readFile as platformReadFile,
writeFile as platformWriteFile,
} from "./platform";
import { useFileSystemStore } from "./store";
import { shouldIgnore } from "./utils";

export async function readFileContent(path: string): Promise<string> {
Expand Down Expand Up @@ -60,42 +58,26 @@ export async function deleteFileOrDirectory(path: string): Promise<void> {
export async function readDirectoryContents(path: string): Promise<FileEntry[]> {
try {
const entries = await platformReadDirectory(path);
const workspaceRoot = useFileSystemStore.getState().rootFolderPath;

const filteredEntries = (entries as any[]).filter((entry: any) => {
const name = entry.name || "Unknown";
const isDir = entry.is_dir || false;
return !shouldIgnore(name, isDir);
});

const entriesWithSymlinkInfo = await Promise.all(
filteredEntries.map(async (entry: any) => {
const entryPath = entry.path || `${path}/${entry.name}`;

try {
const symlinkInfo = await getSymlinkInfo(entryPath, workspaceRoot);

return {
name: entry.name || "Unknown",
path: entryPath,
isDir: symlinkInfo.is_symlink ? false : entry.is_dir || false,
children: undefined,
isSymlink: symlinkInfo.is_symlink,
symlinkTarget: symlinkInfo.target,
};
} catch (error) {
console.error(`Failed to get symlink info for ${entryPath}:`, error);
return {
name: entry.name || "Unknown",
path: entryPath,
isDir: entry.is_dir || false,
children: undefined,
};
}
}),
);

return entriesWithSymlinkInfo;
// Skip per-entry symlink resolution during initial directory read.
// Symlink info is resolved lazily when a file is opened (handleFileSelect)
// or a directory is expanded, avoiding hundreds of stat syscalls that
// cause significant lag on large projects (see #572).
return filteredEntries.map((entry: any) => {
const entryPath = entry.path || `${path}/${entry.name}`;
return {
name: entry.name || "Unknown",
path: entryPath,
isDir: entry.is_dir || false,
children: undefined,
};
});
} catch (error) {
throw new Error(`Failed to read directory ${path}: ${error}`);
}
Expand Down
78 changes: 40 additions & 38 deletions src/features/file-system/controllers/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import {
createNewFile,
deleteFileOrDirectory,
readDirectoryContents,
readFileContent,
} from "./file-operations";
import {
addFileToTree,
Expand Down Expand Up @@ -791,38 +790,6 @@ export const useFileSystemStore = createSelectors(
);
fileOpenBenchmark.finish(path, "binary-buffer-opened");
} else {
if (!path.startsWith("remote://")) {
try {
const fileData = await readFile(resolvedPath);

if (isStaleRequest()) return;

if (isBinaryContent(fileData)) {
openBuffer(
path,
fileName,
"",
false,
undefined,
false,
false,
undefined,
false,
false,
false,
undefined,
false,
false,
true,
);
fileOpenBenchmark.finish(path, "binary-sniff-buffer-opened");
return;
}
} catch (error) {
console.error("Failed to inspect file bytes before opening:", error);
}
}

// Check if external editor is enabled for text files
const { settings } = useSettingsStore.getState();
const { openExternalEditorBuffer } = useBufferStore.getState().actions;
Expand Down Expand Up @@ -867,7 +834,38 @@ export const useFileSystemStore = createSelectors(
filePath: remotePath,
});
} else {
content = await readFileContent(resolvedPath);
// Read file as binary first to perform binary sniffing without
// a separate read pass (avoids reading large files twice, see #572).
const fileData = await readFile(resolvedPath);
fileOpenBenchmark.mark(path, "file-read-bytes", `${fileData.length} bytes`);

if (isStaleRequest()) return;

if (isBinaryContent(fileData)) {
openBuffer(
path,
fileName,
"",
false,
undefined,
false,
false,
undefined,
false,
false,
false,
undefined,
false,
false,
true,
);
fileOpenBenchmark.finish(path, "binary-sniff-buffer-opened");
return;
}

// Decode the already-read bytes instead of reading the file again
const decoder = new TextDecoder("utf-8");
content = decoder.decode(fileData);
}
fileOpenBenchmark.mark(path, "file-read", `${content.length} chars`);

Expand Down Expand Up @@ -1914,6 +1912,14 @@ export const useFileSystemStore = createSelectors(

useWorkspaceTabsStore.getState().setActiveProjectTab(projectId);

// Close old project's buffers BEFORE loading new project to prevent
// race conditions between session save and restore, and to ensure
// terminal PTY processes from the old workspace are cleaned up
// before new ones are spawned.
if (currentBufferIds.length > 0) {
bufferActions.closeBuffersBatch(currentBufferIds, true);
}

if (remoteTabInfo) {
const reconnected = await get().handleOpenRemoteProject(
remoteTabInfo.connectionId,
Expand Down Expand Up @@ -2060,10 +2066,6 @@ export const useFileSystemStore = createSelectors(
})();
}

if (currentBufferIds.length > 0) {
bufferActions.closeBuffersBatch(currentBufferIds, true);
}

set((state) => {
state.isSwitchingProject = false;
});
Expand Down