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
5 changes: 4 additions & 1 deletion apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type VcsSwitchRefInput,
type VcsSwitchRefResult,
type VcsStagePathsInput,
type VcsStagePathsResult,
type VcsCreateRefInput,
type VcsCreateRefResult,
type VcsCreateWorktreeInput,
Expand Down Expand Up @@ -106,7 +107,9 @@ export class GitWorkflowService extends Context.Service<
readonly switchRef: (
input: VcsSwitchRefInput,
) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
readonly stagePaths: (input: VcsStagePathsInput) => Effect.Effect<void, GitCommandError>;
readonly stagePaths: (
input: VcsStagePathsInput,
) => Effect.Effect<VcsStagePathsResult, GitCommandError>;
readonly renameBranch: (input: {
readonly cwd: string;
readonly oldBranch: string;
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
VcsProcessExitError,
type VcsSwitchRefInput,
type VcsStagePathsInput,
type VcsStagePathsResult,
type VcsSwitchRefResult,
type VcsCreateRefInput,
type VcsCreateRefResult,
Expand Down Expand Up @@ -373,7 +374,9 @@ export class GitVcsDriver extends Context.Service<
readonly switchRef: (
input: VcsSwitchRefInput,
) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
readonly stagePaths: (input: VcsStagePathsInput) => Effect.Effect<void, GitCommandError>;
readonly stagePaths: (
input: VcsStagePathsInput,
) => Effect.Effect<VcsStagePathsResult, GitCommandError>;
readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
}
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2545,11 +2545,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* (
input: ReviewDiffPreviewInput,
) {
// Stamped before any repository read, so a client can tell whether the preview saw an
// index change that completed at a known server time.
const generatedAt = yield* DateTime.now;
const details = yield* statusDetailsLocal(input.cwd);
if (!details.isRepo) {
return {
cwd: input.cwd,
generatedAt: yield* DateTime.now,
generatedAt,
sources: [],
};
}
Expand Down Expand Up @@ -2656,7 +2659,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*

return {
cwd: input.cwd,
generatedAt: yield* DateTime.now,
generatedAt,
sources,
};
});
Expand Down Expand Up @@ -3556,6 +3559,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
fallbackErrorDetail: input.staged ? "git add failed" : "git reset failed",
},
);
return { completedAt: yield* DateTime.now };
},
);

Expand Down
77 changes: 49 additions & 28 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,14 @@ export default function DiffPanel({
);
const stagePaths = useAtomCommand(vcsEnvironment.stagePaths, { reportFailure: false });
const [pendingStagePaths, setPendingStagePaths] = useState<ReadonlySet<string>>(EMPTY_PATH_SET);
// Files that a filtered view drops as soon as they are staged or unstaged, so the reader is
// not left waiting on the server to regenerate the preview. Bound to the preview they came
// from; the next refresh carries the real answer.
const [optimisticallyHiddenFiles, setOptimisticallyHiddenFiles] = useState<{
readonly previewKey: string | null;
readonly paths: ReadonlySet<string>;
}>({ previewKey: null, paths: EMPTY_PATH_SET });
// Files a filtered view drops as soon as they are staged or unstaged, so the reader is not
// left waiting on the server to regenerate the preview. Each entry holds the server time the
// index change completed (null while in flight); the file stays hidden until a preview
// generated after that time lands, so a refresh already in flight cannot bring it back.
const [hiddenStagedFiles, setHiddenStagedFiles] = useState<{
readonly scopeKey: string | null;
readonly entries: ReadonlyMap<string, number | null>;
}>({ scopeKey: null, entries: new Map() });
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
Expand Down Expand Up @@ -500,13 +501,19 @@ export default function DiffPanel({
}),
[resolvedTheme, selectedPatch, selectedTurnId],
);
const previewKey = branchDiffPreview.data
? `${DateTime.toEpochMillis(branchDiffPreview.data.generatedAt)}:${selectedGitSource?.diffHash ?? ""}`
const previewGeneratedAt = branchDiffPreview.data
? DateTime.toEpochMillis(branchDiffPreview.data.generatedAt)
: null;
const hiddenFilePaths =
previewKey !== null && optimisticallyHiddenFiles.previewKey === previewKey
? optimisticallyHiddenFiles.paths
: EMPTY_PATH_SET;
const hiddenFilePaths = useMemo(() => {
if (hiddenStagedFiles.scopeKey !== fileSelectionScopeKey) return EMPTY_PATH_SET;
const paths = new Set<string>();
for (const [path, completedAt] of hiddenStagedFiles.entries) {
if (completedAt === null || previewGeneratedAt === null || previewGeneratedAt < completedAt) {
paths.add(path);
}
}
return paths;
}, [fileSelectionScopeKey, hiddenStagedFiles, previewGeneratedAt]);
const renderableFiles = useMemo(() => {
if (!renderablePatch || renderablePatch.kind !== "files") {
return [];
Expand Down Expand Up @@ -729,14 +736,17 @@ export default function DiffPanel({
setPendingStagePaths((current) => new Set([...current, ...paths]));
const leavesFilteredView =
(workingTreeFilter === "unstaged" && staged) || (workingTreeFilter === "staged" && !staged);
const scopeKey = fileSelectionScopeKey;
const updateHiddenFiles = (update: (entries: Map<string, number | null>) => void) =>
setHiddenStagedFiles((current) => {
const entries = new Map(current.scopeKey === scopeKey ? current.entries : []);
update(entries);
return { scopeKey, entries };
});
if (leavesFilteredView) {
setOptimisticallyHiddenFiles((current) => ({
previewKey,
paths: new Set([
...(current.previewKey === previewKey ? current.paths : EMPTY_PATH_SET),
...paths,
]),
}));
updateHiddenFiles((entries) => {
for (const path of paths) entries.set(path, null);
});
}
void (async () => {
const result = await stagePaths({
Expand All @@ -748,14 +758,18 @@ export default function DiffPanel({
for (const path of paths) next.delete(path);
return next;
});
if (leavesFilteredView) {
updateHiddenFiles((entries) => {
for (const path of paths) {
if (result._tag === "Success") {
entries.set(path, DateTime.toEpochMillis(result.value.completedAt));
} else {
entries.delete(path);
}
}
});
}
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
if (leavesFilteredView) {
setOptimisticallyHiddenFiles((current) => {
const next = new Set(current.paths);
for (const path of paths) next.delete(path);
return { previewKey: current.previewKey, paths: next };
});
}
const error = squashAtomCommandFailure(result);
toastManager.add(
stackedThreadToast({
Expand All @@ -768,7 +782,14 @@ export default function DiffPanel({
refreshBranchDiffPreview();
})();
},
[activeThread, previewCwd, previewKey, refreshBranchDiffPreview, stagePaths, workingTreeFilter],
[
activeThread,
fileSelectionScopeKey,
previewCwd,
refreshBranchDiffPreview,
stagePaths,
workingTreeFilter,
],
);
// The filtered views already say which way a file can go; the unfiltered view asks the index.
const stagingActionFor = useCallback(
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ export const VcsStagePathsInput = Schema.Struct({
});
export type VcsStagePathsInput = typeof VcsStagePathsInput.Type;

export const VcsStagePathsResult = Schema.Struct({
/** Server clock when the index was updated; a preview generated later reflects it. */
completedAt: Schema.DateTimeUtc,
});
export type VcsStagePathsResult = typeof VcsStagePathsResult.Type;

export const VcsInitInput = Schema.Struct({
cwd: TrimmedNonEmptyStringSchema,
kind: Schema.optional(VcsDriverKind),
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
GitActionProgressEvent,
VcsSwitchRefInput,
VcsStagePathsInput,
VcsStagePathsResult,
VcsSwitchRefResult,
GitCommandError,
VcsCreateRefInput,
Expand Down Expand Up @@ -1079,6 +1080,7 @@ const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, {

const WsVcsStagePathsRpc = Rpc.make(WS_METHODS.vcsStagePaths, {
payload: VcsStagePathsInput,
success: VcsStagePathsResult,
error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
});

Expand Down
Loading