authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 21:25:31-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 21:25:31-03:00
log36caf200c2b24c12838570966f5c85786a1fc1f8
treeb566cf72b7e3acccf5ba19f7498545cb1c4929c6
parent8ddd9f7efcdfb6e32b614595242b5986942d37b0
signature Signed by PGP key B5690EEEBB952194

feat(web): choose the default diff file state (#11484)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

9 files changed, 114 insertions(+), 13 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -34,6 +34,7 @@ const clientSettings: ClientSettings = {...@@ -34,6 +34,7 @@ const clientSettings: ClientSettings = {
34 contextWindowMeterEnabled: false,34 contextWindowMeterEnabled: false,
35 composerCollapseOnScroll: true,35 composerCollapseOnScroll: true,
36 dismissedProviderUpdateNotificationKeys: [],36 dismissedProviderUpdateNotificationKeys: [],
37 diffFilesCollapsed: true,
37 diffIgnoreWhitespace: true,38 diffIgnoreWhitespace: true,
38 diffLayout: "stacked",39 diffLayout: "stacked",
39 environmentIdentificationMode: "artwork",40 environmentIdentificationMode: "artwork",
apps/web/src/clientPersistenceStorage.test.ts+15
...@@ -114,6 +114,21 @@ describe("clientPersistenceStorage", () => {...@@ -114,6 +114,21 @@ describe("clientPersistenceStorage", () => {
114 expect(settings).not.toHaveProperty("diffWordWrap");114 expect(settings).not.toHaveProperty("diffWordWrap");
115 });115 });
116116
117 it("keeps the default diff file state across reloads and defaults it to expanded", async () => {
118 const testWindow = getTestWindow();
119 const { readBrowserClientSettings, writeBrowserClientSettings } =
120 await import("./clientPersistenceStorage");
121
122 testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({}));
123 expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false);
124
125 writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true });
126 expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true);
127
128 writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false });
129 expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false);
130 });
131
117 it("keeps the diff layout across reloads and defaults it to stacked", async () => {132 it("keeps the diff layout across reloads and defaults it to stacked", async () => {
118 const testWindow = getTestWindow();133 const testWindow = getTestWindow();
119 const { readBrowserClientSettings, writeBrowserClientSettings } =134 const { readBrowserClientSettings, writeBrowserClientSettings } =
apps/web/src/components/DiffPanel.tsx+21-10
...@@ -226,10 +226,6 @@ export default function DiffPanel({...@@ -226,10 +226,6 @@ export default function DiffPanel({
226 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`226 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
227 : null;227 : null;
228 const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;228 const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;
229 const collapsedDiffFileKeys =
230 collapsedDiffFiles.scopeKey === collapseScopeKey
231 ? collapsedDiffFiles.fileKeys
232 : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
233 const reviewSectionTitle = selectedTurn229 const reviewSectionTitle = selectedTurn
234 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`230 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
235 : selectedGitScope === "unstaged"231 : selectedGitScope === "unstaged"
...@@ -425,6 +421,17 @@ export default function DiffPanel({...@@ -425,6 +421,17 @@ export default function DiffPanel({
425 })),421 })),
426 [renderableFiles],422 [renderableFiles],
427 );423 );
424 const defaultCollapsedDiffFileKeys = useMemo(
425 () =>
426 settings.diffFilesCollapsed
427 ? new Set(renderableFileEntries.map((file) => file.fileKey))
428 : EMPTY_COLLAPSED_DIFF_FILE_KEYS,
429 [renderableFileEntries, settings.diffFilesCollapsed],
430 );
431 const collapsedDiffFileKeys =
432 collapsedDiffFiles.scopeKey === collapseScopeKey
433 ? collapsedDiffFiles.fileKeys
434 : defaultCollapsedDiffFileKeys;
428 const codeViewFiles = useMemo(435 const codeViewFiles = useMemo(
429 () =>436 () =>
430 renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => {437 renderableFileEntries.map(({ fileDiff, fileKey, fileVersion }) => {
...@@ -462,14 +469,16 @@ export default function DiffPanel({...@@ -462,14 +469,16 @@ export default function DiffPanel({
462 if (!file) return;469 if (!file) return;
463 if (file.collapsed) {470 if (file.collapsed) {
464 setCollapsedDiffFiles((current) => {471 setCollapsedDiffFiles((current) => {
465 const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []);472 const next = new Set(
473 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
474 );
466 next.delete(file.fileKey);475 next.delete(file.fileKey);
467 return { scopeKey: collapseScopeKey, fileKeys: next };476 return { scopeKey: collapseScopeKey, fileKeys: next };
468 });477 });
469 }478 }
470 requestTreeReveal(file.fileKey);479 requestTreeReveal(file.fileKey);
471 },480 },
472 [codeViewFiles, collapseScopeKey, requestTreeReveal],481 [codeViewFiles, collapseScopeKey, defaultCollapsedDiffFileKeys, requestTreeReveal],
473 );482 );
474483
475 const openDiffFile = useCallback(484 const openDiffFile = useCallback(
...@@ -503,7 +512,9 @@ export default function DiffPanel({...@@ -503,7 +512,9 @@ export default function DiffPanel({
503 const toggleDiffFileCollapsed = useCallback(512 const toggleDiffFileCollapsed = useCallback(
504 (fileKey: string) => {513 (fileKey: string) => {
505 setCollapsedDiffFiles((current) => {514 setCollapsedDiffFiles((current) => {
506 const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []);515 const next = new Set(
516 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
517 );
507 if (next.has(fileKey)) {518 if (next.has(fileKey)) {
508 next.delete(fileKey);519 next.delete(fileKey);
509 } else {520 } else {
...@@ -512,21 +523,21 @@ export default function DiffPanel({...@@ -512,21 +523,21 @@ export default function DiffPanel({
512 return { scopeKey: collapseScopeKey, fileKeys: next };523 return { scopeKey: collapseScopeKey, fileKeys: next };
513 });524 });
514 },525 },
515 [collapseScopeKey],526 [collapseScopeKey, defaultCollapsedDiffFileKeys],
516 );527 );
517528
518 const toggleDiffFileCollapse = useCallback(() => {529 const toggleDiffFileCollapse = useCallback(() => {
519 setCodeViewRevision((current) => current + 1);530 setCodeViewRevision((current) => current + 1);
520 setCollapsedDiffFiles((current) => {531 setCollapsedDiffFiles((current) => {
521 const currentKeys =532 const currentKeys =
522 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;533 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys;
523534
524 return {535 return {
525 scopeKey: collapseScopeKey,536 scopeKey: collapseScopeKey,
526 fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys),537 fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys),
527 };538 };
528 });539 });
529 }, [collapseScopeKey, diffFileKeys]);540 }, [collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]);
530541
531 const selectTurn = (turnId: TurnId) => {542 const selectTurn = (turnId: TurnId) => {
532 if (!routeThreadRef) return;543 if (!routeThreadRef) return;
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+6-1
...@@ -485,7 +485,11 @@ function PullRequestCodeTab({...@@ -485,7 +485,11 @@ function PullRequestCodeTab({
485 groupAt(anchor.side, anchor.line).draft = true;485 groupAt(anchor.side, anchor.line).draft = true;
486 }486 }
487487
488 const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles);488 const collapsed = isFileDiffCollapsed(
489 fileKey,
490 foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"),
491 toggledFiles,
492 );
489493
490 const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({494 const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({
491 side: toViewerSide(group.side),495 side: toViewerSide(group.side),
...@@ -538,6 +542,7 @@ function PullRequestCodeTab({...@@ -538,6 +542,7 @@ function PullRequestCodeTab({
538 foldOverride,542 foldOverride,
539 pendingComments,543 pendingComments,
540 placedThreadIds,544 placedThreadIds,
545 settings.diffFilesCollapsed,
541 toggledFiles,546 toggledFiles,
542 ],547 ],
543 );548 );
apps/web/src/components/pullRequest/pullRequestDiff.logic.ts+2-2
...@@ -30,8 +30,8 @@ export type DiffFoldOverride = "expanded" | "folded" | null;...@@ -30,8 +30,8 @@ export type DiffFoldOverride = "expanded" | "folded" | null;
30 * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from30 * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from
31 * what the toolbar last said rather than as the set of folded files: a file that has not loaded31 * what the toolbar last said rather than as the set of folded files: a file that has not loaded
32 * yet cannot be in a set, and would otherwise land expanded moments after the reader folded32 * yet cannot be in a set, and would otherwise land expanded moments after the reader folded
33 * everything. Files start expanded so opening the Code tab immediately shows the change; the33 * everything. The caller supplies the saved default until the toolbar overrides it; individual
34 * reader can still fold individual files or the whole diff from the toolbar.34 * files can still be toggled independently.
35 */35 */
36export function isFileDiffCollapsed(36export function isFileDiffCollapsed(
37 fileKey: string,37 fileKey: string,
apps/web/src/components/settings/SettingsPanels.tsx+47
...@@ -533,6 +533,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -533,6 +533,9 @@ export function useSettingsRestore(onRestored?: () => void) {
533 : []),533 : []),
534 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),534 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
535 ...getChangedTypographySettingLabels(settings),535 ...getChangedTypographySettingLabels(settings),
536 ...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed
537 ? ["Default diff file state"]
538 : []),
536 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace539 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
537 ? ["Diff whitespace changes"]540 ? ["Diff whitespace changes"]
538 : []),541 : []),
...@@ -608,6 +611,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -608,6 +611,7 @@ export function useSettingsRestore(onRestored?: () => void) {
608 settings.addProjectBaseDirectory,611 settings.addProjectBaseDirectory,
609 settings.defaultThreadEnvMode,612 settings.defaultThreadEnvMode,
610 settings.newWorktreesStartFromOrigin,613 settings.newWorktreesStartFromOrigin,
614 settings.diffFilesCollapsed,
611 settings.diffIgnoreWhitespace,615 settings.diffIgnoreWhitespace,
612 settings.diffLayout,616 settings.diffLayout,
613 settings.proactivePanelsEnabled,617 settings.proactivePanelsEnabled,
...@@ -706,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -706,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) {
706 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,710 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
707 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,711 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
708 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,712 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
713 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
709 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,714 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
710 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,715 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
711 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,716 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
...@@ -2304,6 +2309,48 @@ export function GeneralSettingsPanel() {...@@ -2304,6 +2309,48 @@ export function GeneralSettingsPanel() {
2304 />2309 />
2305 }2310 }
2306 />2311 />
2312 <SettingsRow
2313 {...searchableSetting("default-diff-file-state")}
2314 description="Start with files expanded or collapsed when opening diffs or a pull request's Code tab."
2315 resetAction={
2316 settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed ? (
2317 <SettingResetButton
2318 label="default diff file state"
2319 onClick={() =>
2320 updateSettings({
2321 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
2322 })
2323 }
2324 />
2325 ) : null
2326 }
2327 control={
2328 <Select
2329 value={settings.diffFilesCollapsed ? "collapsed" : "expanded"}
2330 onValueChange={(value) => {
2331 if (value === "expanded" || value === "collapsed") {
2332 updateSettings({ diffFilesCollapsed: value === "collapsed" });
2333 }
2334 }}
2335 >
2336 <SelectTrigger
2337 size="sm"
2338 className="w-full sm:w-40"
2339 aria-label="Default diff file state"
2340 >
2341 <SelectValue>{settings.diffFilesCollapsed ? "Collapsed" : "Expanded"}</SelectValue>
2342 </SelectTrigger>
2343 <SelectPopup align="end" alignItemWithTrigger={false}>
2344 <SelectItem hideIndicator value="expanded">
2345 Expanded
2346 </SelectItem>
2347 <SelectItem hideIndicator value="collapsed">
2348 Collapsed
2349 </SelectItem>
2350 </SelectPopup>
2351 </Select>
2352 }
2353 />
2307 <SettingsRow2354 <SettingsRow
2308 {...searchableSetting("diff-layout")}2355 {...searchableSetting("diff-layout")}
2309 description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too."2356 description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too."
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [
247 to: "/settings/general",247 to: "/settings/general",
248 searchTerms: ["diff ignore spaces edits default"],248 searchTerms: ["diff ignore spaces edits default"],
249 },249 },
250 {
251 id: "default-diff-file-state",
252 title: "Default diff file state",
253 to: "/settings/general",
254 searchTerms: ["collapsed expanded collapse expand files pull request pr code tab"],
255 },
250 {256 {
251 id: "diff-layout",257 id: "diff-layout",
252 title: "Diff layout",258 title: "Diff layout",
packages/contracts/src/settings.test.ts+14
...@@ -164,6 +164,20 @@ describe("ClaudeSettings auto-compaction", () => {...@@ -164,6 +164,20 @@ describe("ClaudeSettings auto-compaction", () => {
164 });164 });
165});165});
166166
167describe("ClientSettings default diff file state", () => {
168 it("keeps files expanded when existing settings omit the preference", () => {
169 expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false);
170 });
171
172 it.each([true, false])("preserves a saved collapsed preference of %s", (diffFilesCollapsed) => {
173 const settings = decodeClientSettings({ diffFilesCollapsed });
174 expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed);
175 expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe(
176 diffFilesCollapsed,
177 );
178 });
179});
180
167describe("ClientSettings diff colors", () => {181describe("ClientSettings diff colors", () => {
168 it("keeps red and green for existing settings without a saved palette", () => {182 it("keeps red and green for existing settings without a saved palette", () => {
169 expect(decodeClientSettings({}).diffColorScheme).toBe("red-green");183 expect(decodeClientSettings({}).diffColorScheme).toBe("red-green");
packages/contracts/src/settings.ts+2
...@@ -344,6 +344,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -344,6 +344,7 @@ export const ClientSettingsSchema = Schema.Struct({
344 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(344 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
345 Schema.withDecodingDefault(Effect.succeed([])),345 Schema.withDecodingDefault(Effect.succeed([])),
346 ),346 ),
347 diffFilesCollapsed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
347 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),348 diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
348 diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))),349 diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))),
349 environmentIdentificationMode: EnvironmentIdentificationMode.pipe(350 environmentIdentificationMode: EnvironmentIdentificationMode.pipe(
...@@ -1432,6 +1433,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1432,6 +1433,7 @@ export const ClientSettingsPatch = Schema.Struct({
1432 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),1433 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
1433 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),1434 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
1434 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),1435 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
1436 diffFilesCollapsed: Schema.optionalKey(Schema.Boolean),
1435 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),1437 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
1436 diffLayout: Schema.optionalKey(DiffLayout),1438 diffLayout: Schema.optionalKey(DiffLayout),
1437 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),1439 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),