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
24 changes: 24 additions & 0 deletions app/src/renderer/hooks/useAgentConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";
import { reportError } from "../error-reporting.js";
import type { AgentConfig } from "@sproutgit/types";

/** Loads the configured coding agent (Settings → AI Agent) once on mount. */
export function useAgentConfig() {
const [agentConfig, setAgentConfig] = useState<AgentConfig | null>(null);

useEffect(() => {
// A failure here leaves the Chat tab looking unconfigured with no
// indication why — surface it instead.
void api
.getAgentConfig()
.then(setAgentConfig)
.catch((err: unknown) =>
reportError("Failed to load agent configuration", err),
);
}, []);

const agentConfigured = !!agentConfig?.command.trim();

return { agentConfig, agentConfigured };
}
41 changes: 41 additions & 0 deletions app/src/renderer/hooks/useAutoUpdateListeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect } from "react";
import { api } from "../api.js";
import { reportError } from "../error-reporting.js";
import { useUpdateStore } from "../stores/update-store.js";

/** Wires electron-updater's IPC events into the update store. */
export function useAutoUpdateListeners() {
const { updateState, setUpdateState } = useUpdateStore();

useEffect(() => {
const offChecking = api.onUpdateChecking(() =>
setUpdateState({ status: "checking" }),
);
const offAvailable = api.onUpdateAvailable((version: string) =>
setUpdateState({ status: "available", version }),
);
const offNotAvailable = api.onUpdateNotAvailable(() =>
setUpdateState({ status: "up-to-date" }),
);
const offDownloading = api.onUpdateDownloading((progress: number) =>
setUpdateState({ status: "downloading", progress }),
);
const offReady = api.onUpdateReady(() =>
setUpdateState({ status: "ready" }),
);
const offError = api.onUpdateError((message: string) => {
reportError("Update failed", message);
setUpdateState({ status: "idle" });
});
return () => {
offChecking();
offAvailable();
offNotAvailable();
offDownloading();
offReady();
offError();
};
}, [setUpdateState]);

return updateState;
}
19 changes: 19 additions & 0 deletions app/src/renderer/hooks/useAvailableShells.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import { api } from "../api.js";

/** Lists the shells available for the terminal's "choose shell" picker. */
export function useAvailableShells() {
const [availableShells, setAvailableShells] = useState<
{ name: string; path: string }[]
>([]);

useEffect(() => {
// Intentionally silent: worst case the shell picker just shows no options.
void api
.listShells()
.then(setAvailableShells)
.catch(() => undefined);
}, []);

return availableShells;
}
104 changes: 104 additions & 0 deletions app/src/renderer/hooks/useCommitDiffState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useState } from "react";
import { api } from "../api.js";
import type { CommitEntry, DiffFileEntry } from "@sproutgit/types";
import type { ToastFn } from "../toast-context.js";

/** Commit/range diff selection state for the Graph tab's diff panel. */
export function useCommitDiffState(params: {
gitRepoPath: string;
toast: ToastFn;
}) {
const { gitRepoPath, toast } = params;

const [selectedCommits, setSelectedCommits] = useState<CommitEntry[]>([]);
const [commitDiffRange, setCommitDiffRange] = useState<string | null>(null);
const [commitDiffFiles, setCommitDiffFiles] = useState<DiffFileEntry[]>([]);
const [commitDiffContent, setCommitDiffContent] = useState("");
const [commitDiffFile, setCommitDiffFile] = useState<DiffFileEntry | null>(
null,
);
const [commitDiffLoading, setCommitDiffLoading] = useState(false);
const [commitDiffFileLoading, setCommitDiffFileLoading] = useState(false);

const selectedCommit = selectedCommits[0] ?? null;

function clearCommitDiff() {
setSelectedCommits([]);
setCommitDiffFiles([]);
setCommitDiffContent("");
setCommitDiffFile(null);
}

async function loadCommitDiff(commit: CommitEntry) {
setSelectedCommits([commit]);
setCommitDiffFile(null);
setCommitDiffContent("");
setCommitDiffLoading(true);
try {
const range =
commit.parents.length > 0
? `${commit.parents[0]}..${commit.hash}`
: commit.hash;
setCommitDiffRange(range);
const files = await api.getDiffFiles(gitRepoPath, range);
setCommitDiffFiles(files as DiffFileEntry[]);
} catch (err) {
toast(`Failed to load commit diff: ${String(err)}`, "error");
setCommitDiffFiles([]);
} finally {
setCommitDiffLoading(false);
}
}

async function loadCommitRangeDiff(from: CommitEntry, to: CommitEntry) {
setSelectedCommits([from, to]);
setCommitDiffFile(null);
setCommitDiffContent("");
setCommitDiffLoading(true);
try {
const range = `${from.hash}..${to.hash}`;
setCommitDiffRange(range);
const files = await api.getDiffFiles(gitRepoPath, range);
setCommitDiffFiles(files as DiffFileEntry[]);
} catch (err) {
toast(`Failed to load range diff: ${String(err)}`, "error");
setCommitDiffFiles([]);
} finally {
setCommitDiffLoading(false);
}
}

async function loadCommitDiffFile(file: DiffFileEntry) {
if (!selectedCommit) return;
setCommitDiffFile(file);
setCommitDiffFileLoading(true);
try {
const range =
commitDiffRange ??
(selectedCommit.parents.length > 0
? `${selectedCommit.parents[0]}..${selectedCommit.hash}`
: selectedCommit.hash);
const content = await api.getDiffContent(gitRepoPath, range, file.path);
setCommitDiffContent(content as string);
} catch (err) {
toast(`Failed to load diff: ${String(err)}`, "error");
setCommitDiffContent("");
} finally {
setCommitDiffFileLoading(false);
}
}

return {
selectedCommits,
selectedCommit,
commitDiffFiles,
commitDiffContent,
commitDiffFile,
commitDiffLoading,
commitDiffFileLoading,
loadCommitDiff,
loadCommitRangeDiff,
loadCommitDiffFile,
clearCommitDiff,
};
}
25 changes: 25 additions & 0 deletions app/src/renderer/hooks/useEditorTabsForWorktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEditorStore, tabKey } from "../stores/editor-store.js";

/** Derives the open editor tabs (and active tab) scoped to one worktree. */
export function useEditorTabsForWorktree(worktreePath: string | undefined) {
const editorTabs = useEditorStore((s) => s.tabs);
const editorTabOrder = useEditorStore((s) => s.tabOrder);
const editorActiveTabKeyRaw = useEditorStore((s) => s.activeTabKey);

const editorTabsForActiveWorktree = editorTabOrder
.map((k) => editorTabs[k])
.filter(
(t): t is NonNullable<typeof t> => !!t && t.worktreePath === worktreePath,
);
const editorActiveTabKey = editorTabsForActiveWorktree.some(
(t) => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKeyRaw,
)
? editorActiveTabKeyRaw
: null;
const activeEditorTab =
editorTabsForActiveWorktree.find(
(t) => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKey,
) ?? null;

return { editorTabsForActiveWorktree, editorActiveTabKey, activeEditorTab };
}
67 changes: 67 additions & 0 deletions app/src/renderer/hooks/useFileEditorActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { api } from "../api.js";
import {
useEditorStore,
openOrFocusTab,
setTabLoaded,
setTabError,
setTabSaved,
resolveConflictReload,
} from "../stores/editor-store.js";
import { useWorkspaceStore } from "../stores/workspace-store.js";
import type { ToastFn } from "../toast-context.js";
import type { WorktreeInfo } from "@sproutgit/types";

/** File editor tab actions: open/read, save, and reload-from-disk (conflict resolution). */
export function useFileEditorActions(params: {
activeWorktree: WorktreeInfo | null;
toast: ToastFn;
}) {
const { activeWorktree, toast } = params;

async function openFile(relativePath: string) {
if (!activeWorktree) return;
const worktreePath = activeWorktree.path;
const key = openOrFocusTab(worktreePath, relativePath);
useWorkspaceStore.setState({ activeTab: "files" });
const tab = useEditorStore.getState().tabs[key];
if (!tab) return;
// "already loaded" means a load previously succeeded, not that the
// content happens to be non-empty — an actually-empty file would
// otherwise get re-read every time the tab is reopened/focused.
if (!tab.loading && !tab.error) return;
try {
const result = await api.readFile(worktreePath, relativePath);
setTabLoaded(key, result.content, result.mtimeMs);
} catch (err) {
setTabError(key, `Failed to read file: ${String(err)}`);
}
}

async function saveFile(key: string) {
const tab = useEditorStore.getState().tabs[key];
if (!tab || !tab.dirty) return;
try {
const result = await api.writeFile(
tab.worktreePath,
tab.relativePath,
tab.content,
);
setTabSaved(key, tab.content, result.mtimeMs);
} catch (err) {
toast(`Failed to save ${tab.relativePath}: ${String(err)}`, "error");
}
}

async function reloadFileFromDisk(key: string) {
const tab = useEditorStore.getState().tabs[key];
if (!tab) return;
try {
const result = await api.readFile(tab.worktreePath, tab.relativePath);
resolveConflictReload(key, result.content, result.mtimeMs);
} catch (err) {
toast(`Failed to reload ${tab.relativePath}: ${String(err)}`, "error");
}
}

return { openFile, saveFile, reloadFileFromDisk };
}
42 changes: 42 additions & 0 deletions app/src/renderer/hooks/useRecentWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { api } from "../api.js";
import type { RecentWorkspace } from "@sproutgit/types";

/** Recent-workspace list for the title bar's workspace switcher, plus switching itself. */
export function useRecentWorkspaces(workspacePath: string) {
const navigate = useNavigate();
const [recentWorkspaces, setRecentWorkspaces] = useState<RecentWorkspace[]>(
[],
);

async function loadRecentWorkspaces(): Promise<RecentWorkspace[]> {
try {
const ws = await api.listRecentWorkspaces();
const sorted = [...ws].sort(
(a, b) =>
new Date(b.lastOpenedAt).getTime() -
new Date(a.lastOpenedAt).getTime(),
);
setRecentWorkspaces(sorted);
return sorted;
} catch {
return recentWorkspaces;
}
}

useEffect(() => {
void loadRecentWorkspaces();
// loadRecentWorkspaces is redefined every render (it closes over
// recentWorkspaces for its error fallback) — only re-run on navigation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspacePath]);

function switchWorkspace(path: string) {
if (path === workspacePath) return;
void api.addRecentWorkspace(path);
void navigate({ to: "/workspace", search: { path } });
}

return { recentWorkspaces, loadRecentWorkspaces, switchWorkspace };
}
Loading
Loading