difit 4.0.6 → 4.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{arc-1g1LrDb3.js → arc-DX2p9X2Y.js} +1 -1
- package/dist/client/assets/architecture-YZFGNWBL-2zVtKbnG.js +1 -0
- package/dist/client/assets/{architectureDiagram-Q4EWVU46-D87-Rmwy.js → architectureDiagram-Q4EWVU46-FixTWViB.js} +1 -1
- package/dist/client/assets/{blockDiagram-DXYQGD6D-Cep-MIFv.js → blockDiagram-DXYQGD6D-CUAMgGr9.js} +1 -1
- package/dist/client/assets/{c4Diagram-AHTNJAMY-BQuH9Txx.js → c4Diagram-AHTNJAMY-BM_HNNZe.js} +1 -1
- package/dist/client/assets/channel-B_ddQhpW.js +1 -0
- package/dist/client/assets/{chunk-2KRD3SAO-CpQQpmvx.js → chunk-2KRD3SAO-DeT59g2K.js} +1 -1
- package/dist/client/assets/{chunk-336JU56O-Ddk9EzgO.js → chunk-336JU56O-CaGvJA86.js} +2 -2
- package/dist/client/assets/chunk-426QAEUC-CMTCMPn4.js +1 -0
- package/dist/client/assets/{chunk-4BX2VUAB-Ca-N0Wd9.js → chunk-4BX2VUAB-D9mNDl5f.js} +1 -1
- package/dist/client/assets/{chunk-4TB4RGXK-ZTWP_Onw.js → chunk-4TB4RGXK-Df3b4HEG.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-Dub40zHG.js → chunk-55IACEB6-dCWLe_n4.js} +1 -1
- package/dist/client/assets/{chunk-5FUZZQ4R-Cgda0gtZ.js → chunk-5FUZZQ4R-EScvXcSN.js} +1 -1
- package/dist/client/assets/{chunk-5PVQY5BW-D8JPH_tm.js → chunk-5PVQY5BW-ail-oj89.js} +1 -1
- package/dist/client/assets/{chunk-67CJDMHE-U1KyLHzG.js → chunk-67CJDMHE-CHeCIL1u.js} +1 -1
- package/dist/client/assets/{chunk-7N4EOEYR-WzOy51nD.js → chunk-7N4EOEYR-P0tNRVMZ.js} +1 -1
- package/dist/client/assets/{chunk-AA7GKIK3-DlWOj4lr.js → chunk-AA7GKIK3-DloBHWSo.js} +1 -1
- package/dist/client/assets/{chunk-BSJP7CBP-CcZ0op08.js → chunk-BSJP7CBP-CGLThsR8.js} +1 -1
- package/dist/client/assets/{chunk-CIAEETIT-qVSphnw5.js → chunk-CIAEETIT-rCt2IEMp.js} +1 -1
- package/dist/client/assets/{chunk-EDXVE4YY-76SPH4sf.js → chunk-EDXVE4YY-DIJEIKIq.js} +1 -1
- package/dist/client/assets/{chunk-ENJZ2VHE-CKULNIzL.js → chunk-ENJZ2VHE-CdrdxFfV.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-CvDPP3mb.js → chunk-FMBD7UC4-BH_GgR9u.js} +1 -1
- package/dist/client/assets/{chunk-FOC6F5B3-DceW0hWA.js → chunk-FOC6F5B3-D71VljSN.js} +1 -1
- package/dist/client/assets/{chunk-ICPOFSXX-ChGBNZMk.js → chunk-ICPOFSXX-2vcQKuhB.js} +1 -1
- package/dist/client/assets/{chunk-K5T4RW27-DBHdC4ln.js → chunk-K5T4RW27-BWIFd7pZ.js} +1 -1
- package/dist/client/assets/{chunk-KGLVRYIC-DRS7yiGQ.js → chunk-KGLVRYIC-Ck8I8tdt.js} +1 -1
- package/dist/client/assets/{chunk-LIHQZDEY-KsE8dyJP.js → chunk-LIHQZDEY-Cc7TtI-w.js} +1 -1
- package/dist/client/assets/{chunk-ORNJ4GCN-Dnp4oHRD.js → chunk-ORNJ4GCN-BMSqiphc.js} +1 -1
- package/dist/client/assets/{chunk-OYMX7WX6-CciaotDu.js → chunk-OYMX7WX6-B5faFb53.js} +1 -1
- package/dist/client/assets/chunk-QZHKN3VN-B-G9G-FB.js +1 -0
- package/dist/client/assets/{chunk-U2HBQHQK-nbp7CjBP.js → chunk-U2HBQHQK-BILTfRyq.js} +1 -1
- package/dist/client/assets/{chunk-X2U36JSP-Chs85loT.js → chunk-X2U36JSP-D4-56gWx.js} +1 -1
- package/dist/client/assets/{chunk-XPW4576I-VtI9b561.js → chunk-XPW4576I-SxB401Zg.js} +1 -1
- package/dist/client/assets/{chunk-YZCP3GAM-sBsewSoO.js → chunk-YZCP3GAM-CWXUVxFj.js} +1 -1
- package/dist/client/assets/{chunk-ZZ45TVLE-TMgeW_px.js → chunk-ZZ45TVLE-CXjZua4f.js} +1 -1
- package/dist/client/assets/classDiagram-6PBFFD2Q-DnUQ2iGN.js +1 -0
- package/dist/client/assets/classDiagram-v2-HSJHXN6E-Dwp5vuOB.js +1 -0
- package/dist/client/assets/clone-aWrl-obY.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-5TzM3w9g.js → cose-bilkent-S5V4N54A-YToNpueF.js} +1 -1
- package/dist/client/assets/{dagre-KV5264BT-xvyFOxd3.js → dagre-KV5264BT-QFYoTa0z.js} +1 -1
- package/dist/client/assets/{dagre-sb6WtN4K.js → dagre-tvaMpP4D.js} +1 -1
- package/dist/client/assets/{diagram-5BDNPKRD-ChRpAe5p.js → diagram-5BDNPKRD-DM0NNmEN.js} +1 -1
- package/dist/client/assets/{diagram-G4DWMVQ6-C_8BED4A.js → diagram-G4DWMVQ6-TiLkMmwt.js} +1 -1
- package/dist/client/assets/{diagram-MMDJMWI5-BMwXEou2.js → diagram-MMDJMWI5-DM1ykqrB.js} +1 -1
- package/dist/client/assets/{diagram-TYMM5635-CeAkx82D.js → diagram-TYMM5635-BEOLX1wr.js} +1 -1
- package/dist/client/assets/{dist-CwC9dd2Z.js → dist-CCBhd9az.js} +1 -1
- package/dist/client/assets/{erDiagram-SMLLAGMA-yGCTeXGt.js → erDiagram-SMLLAGMA-DZcjZq6z.js} +1 -1
- package/dist/client/assets/{flowDiagram-DWJPFMVM-CugkvbmM.js → flowDiagram-DWJPFMVM-B1AVT9es.js} +1 -1
- package/dist/client/assets/{ganttDiagram-T4ZO3ILL-BXnlBFgK.js → ganttDiagram-T4ZO3ILL-BCEXws9V.js} +1 -1
- package/dist/client/assets/gitGraph-7Q5UKJZL-BE3Mcr-v.js +1 -0
- package/dist/client/assets/{gitGraphDiagram-UUTBAWPF-B61aCwwu.js → gitGraphDiagram-UUTBAWPF-CVznBDOl.js} +1 -1
- package/dist/client/assets/{graphlib-BMWKz3zT.js → graphlib-C4fWcyt1.js} +1 -1
- package/dist/client/assets/index-6LShOAAb.js +79 -0
- package/dist/client/assets/index-C16wNcPQ.css +2 -0
- package/dist/client/assets/info-OMHHGYJF-CBpXVhw-.js +1 -0
- package/dist/client/assets/{infoDiagram-42DDH7IO-Bkh6nTL2.js → infoDiagram-42DDH7IO-D8Oxr-KJ.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-UXIWVN3A-D_fdVT6_.js → ishikawaDiagram-UXIWVN3A-BE9KniVE.js} +1 -1
- package/dist/client/assets/{journeyDiagram-VCZTEJTY-DkXVokNF.js → journeyDiagram-VCZTEJTY-B3lGcz06.js} +1 -1
- package/dist/client/assets/{kanban-definition-6JOO6SKY-y8qq7qvL.js → kanban-definition-6JOO6SKY-Bs1QdB0j.js} +1 -1
- package/dist/client/assets/{line-B0LcTqNY.js → line-CO4-KhEq.js} +1 -1
- package/dist/client/assets/{linear-CqIjr2qp.js → linear-CnaJKs0I.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-Du6QzpZO.js → mermaid-parser.core-CravK6bS.js} +2 -2
- package/dist/client/assets/{mermaid.core-CZBu-oKJ.js → mermaid.core-DTh9KJvF.js} +3 -3
- package/dist/client/assets/{mindmap-definition-QFDTVHPH-BJrRxSkM.js → mindmap-definition-QFDTVHPH-D2xU2hfX.js} +1 -1
- package/dist/client/assets/packet-4T2RLAQJ-abaJ3V5T.js +1 -0
- package/dist/client/assets/pie-ZZUOXDRM-B12dpA7V.js +1 -0
- package/dist/client/assets/{pieDiagram-DEJITSTG-Debmhc0u.js → pieDiagram-DEJITSTG-CRX6y4IQ.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-34T5L4WZ-SE3g2BC9.js → quadrantDiagram-34T5L4WZ-K2HFp8O8.js} +1 -1
- package/dist/client/assets/radar-PYXPWWZC-BbBaJJN8.js +1 -0
- package/dist/client/assets/{requirementDiagram-MS252O5E-1mv41puC.js → requirementDiagram-MS252O5E-C-8AW0uI.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-XADWPNL6-CLjPRtOP.js → sankeyDiagram-XADWPNL6-Bv-_ZFS5.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-FGHM5R23-Cs-P3AtR.js → sequenceDiagram-FGHM5R23-Bk4QYIPk.js} +1 -1
- package/dist/client/assets/{src-5XpQHeIJ.js → src-XMuEuFcU.js} +1 -1
- package/dist/client/assets/{stateDiagram-FHFEXIEX-CmB1fohY.js → stateDiagram-FHFEXIEX-CI1G7zGC.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-DQ0U-oto.js +1 -0
- package/dist/client/assets/{timeline-definition-GMOUNBTQ-BMUafJOI.js → timeline-definition-GMOUNBTQ-CnXv8xHg.js} +1 -1
- package/dist/client/assets/treeView-SZITEDCU-CM0rCBUc.js +1 -0
- package/dist/client/assets/treemap-W4RFUUIX-CXoNE_rL.js +1 -0
- package/dist/client/assets/{vennDiagram-DHZGUBPP-CpZ1Qhjz.js → vennDiagram-DHZGUBPP-M5x471Ar.js} +1 -1
- package/dist/client/assets/wardley-RL74JXVD-B_EtnvOk.js +1 -0
- package/dist/client/assets/{wardleyDiagram-NUSXRM2D-C-zH0lsd.js → wardleyDiagram-NUSXRM2D-BG99uPNN.js} +1 -1
- package/dist/client/assets/{xychartDiagram-5P7HB3ND-SkLFuEHZ.js → xychartDiagram-5P7HB3ND-DO7Upr9G.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/site-data/manifest.json +1 -0
- package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
- package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
- package/dist/types/diff.d.ts +11 -0
- package/package.json +2 -2
- package/dist/client/assets/architecture-YZFGNWBL-MZfdAdY6.js +0 -1
- package/dist/client/assets/channel-CJqLEVLU.js +0 -1
- package/dist/client/assets/chunk-426QAEUC-2xhUznDE.js +0 -1
- package/dist/client/assets/chunk-QZHKN3VN-BiVE5u_E.js +0 -1
- package/dist/client/assets/classDiagram-6PBFFD2Q-CfyHazmg.js +0 -1
- package/dist/client/assets/classDiagram-v2-HSJHXN6E-D7Rb-bnu.js +0 -1
- package/dist/client/assets/clone-8xC1huEg.js +0 -1
- package/dist/client/assets/gitGraph-7Q5UKJZL-BeTWkPrd.js +0 -1
- package/dist/client/assets/index-D2Y8-unG.css +0 -2
- package/dist/client/assets/index-D9v_eYzS.js +0 -79
- package/dist/client/assets/info-OMHHGYJF-MUNR2tTt.js +0 -1
- package/dist/client/assets/packet-4T2RLAQJ-Ci-Uu57s.js +0 -1
- package/dist/client/assets/pie-ZZUOXDRM-pm57XGIg.js +0 -1
- package/dist/client/assets/radar-PYXPWWZC-CH-AuSDw.js +0 -1
- package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-D6jsrR-f.js +0 -1
- package/dist/client/assets/treeView-SZITEDCU-BGsVMAdJ.js +0 -1
- package/dist/client/assets/treemap-W4RFUUIX-DXnhegXy.js +0 -1
- package/dist/client/assets/wardley-RL74JXVD-COd5nWj-.js +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"revision":{"id":"7d40fd4...a72112f-comments","demoTitle":"Diff with AI comments","demoTitleByLanguage":{"en":"Diff with AI comments","ja":"AIからのコメントがついたdiff","ko":"AI 코멘트가 있는 diff","zh":"带有 AI 评论的 diff"},"baseHash":"7d40fd4dd1d9a07b68dc67f49738782233d8ed26","baseShortHash":"7d40fd4","targetHash":"a72112ff358e007da257d8ae96b402ce21ed5b4b","targetShortHash":"a72112f","message":"Honor reduce-motion preference for diff scroll animations","authorName":"OKUNOKENTARO","date":"2026-05-02T16:41:56+09:00"},"diff":{"commit":"7d40fd4...a72112f","files":[{"path":"src/client/App.tsx","status":"modified","additions":2,"deletions":1,"chunks":[{"header":"@@ -147,7 +147,7 @@ function App() {","oldStart":147,"oldLines":7,"newStart":147,"newLines":7,"lines":[{"type":"normal","content":" return getDiffSelectionKey(resolvedSelection);","oldLineNumber":147,"newLineNumber":147},{"type":"normal","content":" }, [resolvedSelection]);","oldLineNumber":148,"newLineNumber":148},{"type":"normal","content":"","oldLineNumber":149,"newLineNumber":149},{"type":"delete","content":" const { settings, updateSettings } = useAppearanceSettings();","oldLineNumber":150},{"type":"add","content":" const { settings, updateSettings, scrollBehavior } = useAppearanceSettings();","newLineNumber":150},{"type":"normal","content":" const { isMobile, isDesktop } = useViewport();","oldLineNumber":151,"newLineNumber":151},{"type":"normal","content":"","oldLineNumber":152,"newLineNumber":152},{"type":"normal","content":" // New diff-aware comment system","oldLineNumber":153,"newLineNumber":153}]},{"header":"@@ -307,6 +307,7 @@ function App() {","oldStart":307,"oldLines":6,"newStart":307,"newLines":7,"lines":[{"type":"normal","content":" diffData,","oldLineNumber":307,"newLineNumber":307},{"type":"normal","content":" diffScrollContainerRef,","oldLineNumber":308,"newLineNumber":308},{"type":"normal","content":" setDiffData,","oldLineNumber":309,"newLineNumber":309},{"type":"add","content":" scrollBehavior,","newLineNumber":310},{"type":"normal","content":" });","oldLineNumber":310,"newLineNumber":311},{"type":"normal","content":"","oldLineNumber":311,"newLineNumber":312},{"type":"normal","content":" const toggleFileReviewed = useCallback(","oldLineNumber":312,"newLineNumber":313}]}],"isGenerated":false},{"path":"src/client/components/SettingsModal.test.tsx","status":"modified","additions":1,"deletions":0,"chunks":[{"header":"@@ -25,6 +25,7 @@ const baseSettings = {","oldStart":25,"oldLines":6,"newStart":25,"newLines":7,"lines":[{"type":"normal","content":" syntaxTheme: 'vsDark',","oldLineNumber":25,"newLineNumber":25},{"type":"normal","content":" editor: 'cursor' as const,","oldLineNumber":26,"newLineNumber":26},{"type":"normal","content":" colorVision: 'normal' as const,","oldLineNumber":27,"newLineNumber":27},{"type":"add","content":" scrollAnimation: 'auto' as const,","newLineNumber":28},{"type":"normal","content":" autoViewedPatterns: [],","oldLineNumber":28,"newLineNumber":29},{"type":"normal","content":"};","oldLineNumber":29,"newLineNumber":30},{"type":"normal","content":"","oldLineNumber":30,"newLineNumber":31}]}],"isGenerated":false},{"path":"src/client/components/SettingsModal.tsx","status":"modified","additions":51,"deletions":0,"chunks":[{"header":"@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';","oldStart":3,"oldLines":6,"newStart":3,"newLines":7,"lines":[{"type":"normal","content":"import { useHotkeysContext } from 'react-hotkeys-hook';","oldLineNumber":3,"newLineNumber":3},{"type":"normal","content":"","oldLineNumber":4,"newLineNumber":4},{"type":"normal","content":"import { DEFAULT_EDITOR_ID, EDITOR_OPTIONS, type EditorOptionId } from '../../utils/editorOptions';","oldLineNumber":5,"newLineNumber":5},{"type":"add","content":"import { type ScrollAnimationSetting } from '../hooks/usePreferredScrollBehavior';","newLineNumber":6},{"type":"normal","content":"import type { ColorVisionMode } from '../utils/appearanceTheme';","oldLineNumber":6,"newLineNumber":7},{"type":"normal","content":"import { formatAutoViewedPatterns, parseAutoViewedPatterns } from '../utils/autoViewedPatterns';","oldLineNumber":7,"newLineNumber":8},{"type":"normal","content":"import {","oldLineNumber":8,"newLineNumber":9}]},{"header":"@@ -19,6 +20,7 @@ interface AppearanceSettings {","oldStart":19,"oldLines":6,"newStart":20,"newLines":7,"lines":[{"type":"normal","content":" syntaxTheme: string;","oldLineNumber":19,"newLineNumber":20},{"type":"normal","content":" editor: EditorOptionId;","oldLineNumber":20,"newLineNumber":21},{"type":"normal","content":" colorVision: ColorVisionMode;","oldLineNumber":21,"newLineNumber":22},{"type":"add","content":" scrollAnimation: ScrollAnimationSetting;","newLineNumber":23},{"type":"normal","content":" autoViewedPatterns: string[];","oldLineNumber":22,"newLineNumber":24},{"type":"normal","content":"}","oldLineNumber":23,"newLineNumber":25},{"type":"normal","content":"","oldLineNumber":24,"newLineNumber":26}]},{"header":"@@ -39,6 +41,7 @@ const DEFAULT_SETTINGS: AppearanceSettings = {","oldStart":39,"oldLines":6,"newStart":41,"newLines":7,"lines":[{"type":"normal","content":" syntaxTheme: 'vsDark',","oldLineNumber":39,"newLineNumber":41},{"type":"normal","content":" editor: DEFAULT_EDITOR_ID,","oldLineNumber":40,"newLineNumber":42},{"type":"normal","content":" colorVision: 'normal',","oldLineNumber":41,"newLineNumber":43},{"type":"add","content":" scrollAnimation: 'auto',","newLineNumber":44},{"type":"normal","content":" autoViewedPatterns: [],","oldLineNumber":42,"newLineNumber":45},{"type":"normal","content":"};","oldLineNumber":43,"newLineNumber":46},{"type":"normal","content":"","oldLineNumber":44,"newLineNumber":47}]},{"header":"@@ -63,6 +66,16 @@ const COLOR_VISION_MODES = [","oldStart":63,"oldLines":6,"newStart":66,"newLines":16,"lines":[{"type":"normal","content":" },","oldLineNumber":63,"newLineNumber":66},{"type":"normal","content":"] as const;","oldLineNumber":64,"newLineNumber":67},{"type":"normal","content":"","oldLineNumber":65,"newLineNumber":68},{"type":"add","content":"const SCROLL_ANIMATION_MODES = [","newLineNumber":69},{"type":"add","content":" {","newLineNumber":70},{"type":"add","content":" id: 'auto',","newLineNumber":71},{"type":"add","content":" label: 'Auto',","newLineNumber":72},{"type":"add","content":" tooltip: 'Follows the OS prefers-reduced-motion setting.',","newLineNumber":73},{"type":"add","content":" },","newLineNumber":74},{"type":"add","content":" { id: 'enabled', label: 'Enabled' },","newLineNumber":75},{"type":"add","content":" { id: 'disabled', label: 'Disabled' },","newLineNumber":76},{"type":"add","content":"] as const;","newLineNumber":77},{"type":"add","content":"","newLineNumber":78},{"type":"normal","content":"const SETTINGS_SECTIONS = [","oldLineNumber":66,"newLineNumber":79},{"type":"normal","content":" {","oldLineNumber":67,"newLineNumber":80},{"type":"normal","content":" id: 'appearance',","oldLineNumber":68,"newLineNumber":81}]},{"header":"@@ -144,6 +157,7 @@ export function SettingsModal({ isOpen, onClose, settings, onSettingsChange }: S","oldStart":144,"oldLines":6,"newStart":157,"newLines":7,"lines":[{"type":"normal","content":" theme: DEFAULT_SETTINGS.theme,","oldLineNumber":144,"newLineNumber":157},{"type":"normal","content":" syntaxTheme: DEFAULT_SETTINGS.syntaxTheme,","oldLineNumber":145,"newLineNumber":158},{"type":"normal","content":" colorVision: DEFAULT_SETTINGS.colorVision,","oldLineNumber":146,"newLineNumber":159},{"type":"add","content":" scrollAnimation: DEFAULT_SETTINGS.scrollAnimation,","newLineNumber":160},{"type":"normal","content":" });","oldLineNumber":147,"newLineNumber":161},{"type":"normal","content":" return;","oldLineNumber":148,"newLineNumber":162},{"type":"normal","content":" }","oldLineNumber":149,"newLineNumber":163}]},{"header":"@@ -303,6 +317,43 @@ export function SettingsModal({ isOpen, onClose, settings, onSettingsChange }: S","oldStart":303,"oldLines":6,"newStart":317,"newLines":43,"lines":[{"type":"normal","content":" </div>","oldLineNumber":303,"newLineNumber":317},{"type":"normal","content":" </div>","oldLineNumber":304,"newLineNumber":318},{"type":"normal","content":"","oldLineNumber":305,"newLineNumber":319},{"type":"add","content":" <div>","newLineNumber":320},{"type":"add","content":" <label className=\"block text-sm font-medium text-github-text-primary mb-2\">","newLineNumber":321},{"type":"add","content":" Scroll Animation","newLineNumber":322},{"type":"add","content":" </label>","newLineNumber":323},{"type":"add","content":" <div className=\"flex gap-2\">","newLineNumber":324},{"type":"add","content":" {SCROLL_ANIMATION_MODES.map((mode) => {","newLineNumber":325},{"type":"add","content":" const isSelected = (settings.scrollAnimation ?? 'auto') === mode.id;","newLineNumber":326},{"type":"add","content":" const button = (","newLineNumber":327},{"type":"add","content":" <button","newLineNumber":328},{"type":"add","content":" key={mode.id}","newLineNumber":329},{"type":"add","content":" type=\"button\"","newLineNumber":330},{"type":"add","content":" onClick={() =>","newLineNumber":331},{"type":"add","content":" onSettingsChange({ ...settings, scrollAnimation: mode.id })","newLineNumber":332},{"type":"add","content":" }","newLineNumber":333},{"type":"add","content":" className={`px-3 py-2 text-sm rounded border transition-colors ${","newLineNumber":334},{"type":"add","content":" isSelected","newLineNumber":335},{"type":"add","content":" ? 'bg-github-accent text-white border-github-accent'","newLineNumber":336},{"type":"add","content":" : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'","newLineNumber":337},{"type":"add","content":" }`}","newLineNumber":338},{"type":"add","content":" >","newLineNumber":339},{"type":"add","content":" {mode.label}","newLineNumber":340},{"type":"add","content":" </button>","newLineNumber":341},{"type":"add","content":" );","newLineNumber":342},{"type":"add","content":"","newLineNumber":343},{"type":"add","content":" if (!('tooltip' in mode)) {","newLineNumber":344},{"type":"add","content":" return button;","newLineNumber":345},{"type":"add","content":" }","newLineNumber":346},{"type":"add","content":"","newLineNumber":347},{"type":"add","content":" return (","newLineNumber":348},{"type":"add","content":" <Tooltip key={mode.id} content={mode.tooltip}>","newLineNumber":349},{"type":"add","content":" {button}","newLineNumber":350},{"type":"add","content":" </Tooltip>","newLineNumber":351},{"type":"add","content":" );","newLineNumber":352},{"type":"add","content":" })}","newLineNumber":353},{"type":"add","content":" </div>","newLineNumber":354},{"type":"add","content":" </div>","newLineNumber":355},{"type":"add","content":"","newLineNumber":356},{"type":"normal","content":" <div>","oldLineNumber":306,"newLineNumber":357},{"type":"normal","content":" <label className=\"block text-sm font-medium text-github-text-primary mb-2\">","oldLineNumber":307,"newLineNumber":358},{"type":"normal","content":" Syntax Highlighting Theme","oldLineNumber":308,"newLineNumber":359}]}],"isGenerated":false},{"path":"src/client/hooks/useAppearanceSettings.test.ts","status":"modified","additions":59,"deletions":0,"chunks":[{"header":"@@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';","oldStart":4,"oldLines":6,"newStart":4,"newLines":13,"lines":[{"type":"normal","content":"import { APPEARANCE_STORAGE_KEY } from '../utils/appearanceTheme';","oldLineNumber":4,"newLineNumber":4},{"type":"normal","content":"","oldLineNumber":5,"newLineNumber":5},{"type":"normal","content":"import { useAppearanceSettings } from './useAppearanceSettings';","oldLineNumber":6,"newLineNumber":6},{"type":"add","content":"import { usePreferredScrollBehavior } from './usePreferredScrollBehavior';","newLineNumber":7},{"type":"add","content":"","newLineNumber":8},{"type":"add","content":"vi.mock('./usePreferredScrollBehavior', () => ({","newLineNumber":9},{"type":"add","content":" usePreferredScrollBehavior: vi.fn(),","newLineNumber":10},{"type":"add","content":"}));","newLineNumber":11},{"type":"add","content":"","newLineNumber":12},{"type":"add","content":"const mockedUsePreferredScrollBehavior = vi.mocked(usePreferredScrollBehavior);","newLineNumber":13},{"type":"normal","content":"","oldLineNumber":7,"newLineNumber":14},{"type":"normal","content":"const setMatchMedia = (initialMatches: boolean) => {","oldLineNumber":8,"newLineNumber":15},{"type":"normal","content":" let matches = initialMatches;","oldLineNumber":9,"newLineNumber":16}]},{"header":"@@ -48,6 +55,10 @@ const setMatchMedia = (initialMatches: boolean) => {","oldStart":48,"oldLines":6,"newStart":55,"newLines":10,"lines":[{"type":"normal","content":"describe('useAppearanceSettings', () => {","oldLineNumber":48,"newLineNumber":55},{"type":"normal","content":" beforeEach(() => {","oldLineNumber":49,"newLineNumber":56},{"type":"normal","content":" localStorage.clear();","oldLineNumber":50,"newLineNumber":57},{"type":"add","content":" });","newLineNumber":58},{"type":"add","content":"","newLineNumber":59},{"type":"add","content":" describe('theme', () => {","newLineNumber":60},{"type":"add","content":" beforeEach(() => {","newLineNumber":61},{"type":"normal","content":" document.documentElement.removeAttribute('data-color-vision');","oldLineNumber":51,"newLineNumber":62},{"type":"normal","content":" document.documentElement.removeAttribute('data-theme');","oldLineNumber":52,"newLineNumber":63},{"type":"normal","content":" document.documentElement.removeAttribute('style');","oldLineNumber":53,"newLineNumber":64}]},{"header":"@@ -86,3 +97,51 @@ describe('useAppearanceSettings', () => {","oldStart":86,"oldLines":3,"newStart":97,"newLines":51,"lines":[{"type":"normal","content":" });","oldLineNumber":86,"newLineNumber":97},{"type":"normal","content":" });","oldLineNumber":87,"newLineNumber":98},{"type":"normal","content":" });","oldLineNumber":88,"newLineNumber":99},{"type":"add","content":"","newLineNumber":100},{"type":"add","content":" describe('scrollBehavior', () => {","newLineNumber":101},{"type":"add","content":" beforeEach(() => {","newLineNumber":102},{"type":"add","content":" mockedUsePreferredScrollBehavior.mockReset();","newLineNumber":103},{"type":"add","content":" mockedUsePreferredScrollBehavior.mockReturnValue('smooth');","newLineNumber":104},{"type":"add","content":" setMatchMedia(false);","newLineNumber":105},{"type":"add","content":" });","newLineNumber":106},{"type":"add","content":"","newLineNumber":107},{"type":"add","content":" it(\"defaults scrollAnimation to 'auto' and forwards it to usePreferredScrollBehavior\", () => {","newLineNumber":108},{"type":"add","content":" const { result } = renderHook(() => useAppearanceSettings());","newLineNumber":109},{"type":"add","content":"","newLineNumber":110},{"type":"add","content":" expect(result.current.settings.scrollAnimation).toBe('auto');","newLineNumber":111},{"type":"add","content":" expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('auto');","newLineNumber":112},{"type":"add","content":" });","newLineNumber":113},{"type":"add","content":"","newLineNumber":114},{"type":"add","content":" it('forwards scrollAnimation loaded from localStorage to usePreferredScrollBehavior', () => {","newLineNumber":115},{"type":"add","content":" localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify({ scrollAnimation: 'enabled' }));","newLineNumber":116},{"type":"add","content":" const { result } = renderHook(() => useAppearanceSettings());","newLineNumber":117},{"type":"add","content":"","newLineNumber":118},{"type":"add","content":" expect(result.current.settings.scrollAnimation).toBe('enabled');","newLineNumber":119},{"type":"add","content":" expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('enabled');","newLineNumber":120},{"type":"add","content":" });","newLineNumber":121},{"type":"add","content":"","newLineNumber":122},{"type":"add","content":" it('returns whatever usePreferredScrollBehavior resolves', () => {","newLineNumber":123},{"type":"add","content":" mockedUsePreferredScrollBehavior.mockReturnValue('instant');","newLineNumber":124},{"type":"add","content":" const { result } = renderHook(() => useAppearanceSettings());","newLineNumber":125},{"type":"add","content":"","newLineNumber":126},{"type":"add","content":" expect(result.current.scrollBehavior).toBe('instant');","newLineNumber":127},{"type":"add","content":" });","newLineNumber":128},{"type":"add","content":"","newLineNumber":129},{"type":"add","content":" it('persists updated scrollAnimation and forwards the new value to usePreferredScrollBehavior', () => {","newLineNumber":130},{"type":"add","content":" const { result } = renderHook(() => useAppearanceSettings());","newLineNumber":131},{"type":"add","content":"","newLineNumber":132},{"type":"add","content":" act(() => {","newLineNumber":133},{"type":"add","content":" result.current.updateSettings({","newLineNumber":134},{"type":"add","content":" ...result.current.settings,","newLineNumber":135},{"type":"add","content":" scrollAnimation: 'disabled',","newLineNumber":136},{"type":"add","content":" });","newLineNumber":137},{"type":"add","content":" });","newLineNumber":138},{"type":"add","content":"","newLineNumber":139},{"type":"add","content":" expect(result.current.settings.scrollAnimation).toBe('disabled');","newLineNumber":140},{"type":"add","content":" expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('disabled');","newLineNumber":141},{"type":"add","content":" expect(JSON.parse(localStorage.getItem(APPEARANCE_STORAGE_KEY) ?? '{}')).toMatchObject({","newLineNumber":142},{"type":"add","content":" scrollAnimation: 'disabled',","newLineNumber":143},{"type":"add","content":" });","newLineNumber":144},{"type":"add","content":" });","newLineNumber":145},{"type":"add","content":" });","newLineNumber":146},{"type":"add","content":"});","newLineNumber":147}]}],"isGenerated":false},{"path":"src/client/hooks/useAppearanceSettings.ts","status":"modified","additions":21,"deletions":8,"chunks":[{"header":"@@ -1,4 +1,4 @@","oldStart":1,"oldLines":4,"newStart":1,"newLines":4,"lines":[{"type":"delete","content":"import { useState, useEffect, useCallback } from 'react';","oldLineNumber":1},{"type":"add","content":"import { useState, useEffect, useCallback, useMemo } from 'react';","newLineNumber":1},{"type":"normal","content":"","oldLineNumber":2,"newLineNumber":2},{"type":"normal","content":"import { DEFAULT_EDITOR_ID } from '../../utils/editorOptions';","oldLineNumber":3,"newLineNumber":3},{"type":"normal","content":"import type { AppearanceSettings } from '../components/SettingsModal';","oldLineNumber":4,"newLineNumber":4}]},{"header":"@@ -11,6 +11,7 @@ import {","oldStart":11,"oldLines":6,"newStart":11,"newLines":7,"lines":[{"type":"normal","content":" type ResolvedTheme,","oldLineNumber":11,"newLineNumber":11},{"type":"normal","content":"} from '../utils/appearanceTheme';","oldLineNumber":12,"newLineNumber":12},{"type":"normal","content":"import { getFallbackSyntaxTheme, isSyntaxThemeForResolvedTheme } from '../utils/themeLoader';","oldLineNumber":13,"newLineNumber":13},{"type":"add","content":"import { usePreferredScrollBehavior } from './usePreferredScrollBehavior';","newLineNumber":14},{"type":"normal","content":"","oldLineNumber":14,"newLineNumber":15},{"type":"normal","content":"const DEFAULT_SETTINGS: AppearanceSettings = {","oldLineNumber":15,"newLineNumber":16},{"type":"normal","content":" fontSize: 14,","oldLineNumber":16,"newLineNumber":17}]},{"header":"@@ -20,10 +21,17 @@ const DEFAULT_SETTINGS: AppearanceSettings = {","oldStart":20,"oldLines":10,"newStart":21,"newLines":17,"lines":[{"type":"normal","content":" syntaxTheme: 'vsDark',","oldLineNumber":20,"newLineNumber":21},{"type":"normal","content":" editor: DEFAULT_EDITOR_ID,","oldLineNumber":21,"newLineNumber":22},{"type":"normal","content":" colorVision: 'normal',","oldLineNumber":22,"newLineNumber":23},{"type":"add","content":" scrollAnimation: 'auto',","newLineNumber":24},{"type":"normal","content":" autoViewedPatterns: [],","oldLineNumber":23,"newLineNumber":25},{"type":"normal","content":"};","oldLineNumber":24,"newLineNumber":26},{"type":"normal","content":"","oldLineNumber":25,"newLineNumber":27},{"type":"delete","content":"export function useAppearanceSettings() {","oldLineNumber":26},{"type":"add","content":"interface UseAppearanceSettingsReturn {","newLineNumber":28},{"type":"add","content":" settings: AppearanceSettings;","newLineNumber":29},{"type":"add","content":" updateSettings: (newSettings: AppearanceSettings) => void;","newLineNumber":30},{"type":"add","content":" readonly scrollBehavior: ScrollBehavior;","newLineNumber":31},{"type":"add","content":"}","newLineNumber":32},{"type":"add","content":"","newLineNumber":33},{"type":"add","content":"export function useAppearanceSettings(): UseAppearanceSettingsReturn {","newLineNumber":34},{"type":"normal","content":" const [settings, setSettings] = useState<AppearanceSettings>(() => {","oldLineNumber":27,"newLineNumber":35},{"type":"normal","content":" try {","oldLineNumber":28,"newLineNumber":36},{"type":"normal","content":" const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY);","oldLineNumber":29,"newLineNumber":37}]},{"header":"@@ -118,13 +126,18 @@ export function useAppearanceSettings() {","oldStart":118,"oldLines":13,"newStart":126,"newLines":18,"lines":[{"type":"normal","content":" }","oldLineNumber":118,"newLineNumber":126},{"type":"normal","content":" }, [settings, applyTheme, getSettingsForResolvedTheme, saveSettings]);","oldLineNumber":119,"newLineNumber":127},{"type":"normal","content":"","oldLineNumber":120,"newLineNumber":128},{"type":"delete","content":" const updateSettings = (newSettings: AppearanceSettings) => {","oldLineNumber":121},{"type":"add","content":" const updateSettings = useCallback(","newLineNumber":129},{"type":"add","content":" (newSettings: AppearanceSettings) => {","newLineNumber":130},{"type":"normal","content":" setSettings(newSettings);","oldLineNumber":122,"newLineNumber":131},{"type":"normal","content":" saveSettings(newSettings);","oldLineNumber":123,"newLineNumber":132},{"type":"delete","content":" };","oldLineNumber":124},{"type":"add","content":" },","newLineNumber":133},{"type":"add","content":" [saveSettings],","newLineNumber":134},{"type":"add","content":" );","newLineNumber":135},{"type":"normal","content":"","oldLineNumber":125,"newLineNumber":136},{"type":"delete","content":" return {","oldLineNumber":126},{"type":"delete","content":" settings,","oldLineNumber":127},{"type":"delete","content":" updateSettings,","oldLineNumber":128},{"type":"delete","content":" };","oldLineNumber":129},{"type":"add","content":" const scrollBehavior = usePreferredScrollBehavior(settings.scrollAnimation);","newLineNumber":137},{"type":"add","content":"","newLineNumber":138},{"type":"add","content":" return useMemo(","newLineNumber":139},{"type":"add","content":" () => ({ settings, updateSettings, scrollBehavior }),","newLineNumber":140},{"type":"add","content":" [settings, updateSettings, scrollBehavior],","newLineNumber":141},{"type":"add","content":" );","newLineNumber":142},{"type":"normal","content":"}","oldLineNumber":130,"newLineNumber":143}]}],"isGenerated":false},{"path":"src/client/hooks/useLazyDiffRendering.ts","status":"modified","additions":5,"deletions":6,"chunks":[{"header":"@@ -12,6 +12,7 @@ interface UseLazyDiffRenderingOptions {","oldStart":12,"oldLines":6,"newStart":12,"newLines":7,"lines":[{"type":"normal","content":" diffData: DiffResponse | null;","oldLineNumber":12,"newLineNumber":12},{"type":"normal","content":" diffScrollContainerRef: React.RefObject<HTMLElement | null>;","oldLineNumber":13,"newLineNumber":13},{"type":"normal","content":" setDiffData: React.Dispatch<React.SetStateAction<DiffResponse | null>>;","oldLineNumber":14,"newLineNumber":14},{"type":"add","content":" scrollBehavior: ScrollBehavior;","newLineNumber":15},{"type":"normal","content":"}","oldLineNumber":15,"newLineNumber":16},{"type":"normal","content":"","oldLineNumber":16,"newLineNumber":17},{"type":"normal","content":"interface UseLazyDiffRenderingReturn {","oldLineNumber":17,"newLineNumber":18}]},{"header":"@@ -26,6 +27,7 @@ export function useLazyDiffRendering({","oldStart":26,"oldLines":6,"newStart":27,"newLines":7,"lines":[{"type":"normal","content":" diffData,","oldLineNumber":26,"newLineNumber":27},{"type":"normal","content":" diffScrollContainerRef,","oldLineNumber":27,"newLineNumber":28},{"type":"normal","content":" setDiffData,","oldLineNumber":28,"newLineNumber":29},{"type":"add","content":" scrollBehavior,","newLineNumber":30},{"type":"normal","content":"}: UseLazyDiffRenderingOptions): UseLazyDiffRenderingReturn {","oldLineNumber":29,"newLineNumber":31},{"type":"normal","content":" const [renderedFilePaths, setRenderedFilePaths] = useState<Set<string>>(new Set());","oldLineNumber":30,"newLineNumber":32},{"type":"normal","content":" const renderedFilePathsRef = useRef<Set<string>>(new Set());","oldLineNumber":31,"newLineNumber":33}]},{"header":"@@ -187,9 +189,6 @@ export function useLazyDiffRendering({","oldStart":187,"oldLines":9,"newStart":189,"newLines":6,"lines":[{"type":"normal","content":" const requestId = scrollRequestIdRef.current + 1;","oldLineNumber":187,"newLineNumber":189},{"type":"normal","content":" scrollRequestIdRef.current = requestId;","oldLineNumber":188,"newLineNumber":190},{"type":"normal","content":"","oldLineNumber":189,"newLineNumber":191},{"type":"delete","content":" const reduceMotion = false;","oldLineNumber":190},{"type":"delete","content":" const behavior: ScrollBehavior = reduceMotion ? 'instant' : 'smooth';","oldLineNumber":191},{"type":"delete","content":"","oldLineNumber":192},{"type":"normal","content":" const areRequiredSectionsReady = () => {","oldLineNumber":193,"newLineNumber":192},{"type":"normal","content":" for (const sectionId of requiredSectionIds) {","oldLineNumber":194,"newLineNumber":193},{"type":"normal","content":" const sectionNode = document.getElementById(sectionId);","oldLineNumber":195,"newLineNumber":194}]},{"header":"@@ -233,7 +232,7 @@ export function useLazyDiffRendering({","oldStart":233,"oldLines":7,"newStart":232,"newLines":7,"lines":[{"type":"normal","content":" return;","oldLineNumber":233,"newLineNumber":232},{"type":"normal","content":" }","oldLineNumber":234,"newLineNumber":233},{"type":"normal","content":"","oldLineNumber":235,"newLineNumber":234},{"type":"delete","content":" if (!tryScroll(behavior)) {","oldLineNumber":236},{"type":"add","content":" if (!tryScroll(scrollBehavior)) {","newLineNumber":235},{"type":"normal","content":" if (attempts < SIDEBAR_SCROLL_MAX_ATTEMPTS) {","oldLineNumber":237,"newLineNumber":236},{"type":"normal","content":" attempts++;","oldLineNumber":238,"newLineNumber":237},{"type":"normal","content":" attemptScroll();","oldLineNumber":239,"newLineNumber":238}]},{"header":"@@ -246,13 +245,13 @@ export function useLazyDiffRendering({","oldStart":246,"oldLines":13,"newStart":245,"newLines":13,"lines":[{"type":"normal","content":" return;","oldLineNumber":246,"newLineNumber":245},{"type":"normal","content":" }","oldLineNumber":247,"newLineNumber":246},{"type":"normal","content":" // Re-run smooth scroll after layout settles to absorb lazy-render shifts.","oldLineNumber":248,"newLineNumber":247},{"type":"delete","content":" tryScroll(behavior);","oldLineNumber":249},{"type":"add","content":" tryScroll(scrollBehavior);","newLineNumber":248},{"type":"normal","content":" }, SIDEBAR_SCROLL_CORRECTION_DELAY_MS);","oldLineNumber":250,"newLineNumber":249},{"type":"normal","content":" });","oldLineNumber":251,"newLineNumber":250},{"type":"normal","content":" };","oldLineNumber":252,"newLineNumber":251},{"type":"normal","content":" attemptScroll();","oldLineNumber":253,"newLineNumber":252},{"type":"normal","content":" },","oldLineNumber":254,"newLineNumber":253},{"type":"delete","content":" [diffData, diffScrollContainerRef, ensureFilesRenderedUpTo],","oldLineNumber":255},{"type":"add","content":" [diffData, diffScrollContainerRef, ensureFilesRenderedUpTo, scrollBehavior],","newLineNumber":254},{"type":"normal","content":" );","oldLineNumber":256,"newLineNumber":255},{"type":"normal","content":"","oldLineNumber":257,"newLineNumber":256},{"type":"normal","content":" useEffect(() => {","oldLineNumber":258,"newLineNumber":257}]}],"isGenerated":false},{"path":"src/client/hooks/usePreferredScrollBehavior.test.ts","status":"added","additions":96,"deletions":0,"chunks":[{"header":"@@ -0,0 +1,96 @@","oldStart":0,"oldLines":0,"newStart":1,"newLines":96,"lines":[{"type":"add","content":"import { act, renderHook, waitFor } from '@testing-library/react';","newLineNumber":1},{"type":"add","content":"import { beforeEach, describe, expect, it, vi } from 'vitest';","newLineNumber":2},{"type":"add","content":"","newLineNumber":3},{"type":"add","content":"import { usePreferredScrollBehavior } from './usePreferredScrollBehavior';","newLineNumber":4},{"type":"add","content":"","newLineNumber":5},{"type":"add","content":"const setMatchMedia = (initialMatches: boolean) => {","newLineNumber":6},{"type":"add","content":" let matches = initialMatches;","newLineNumber":7},{"type":"add","content":" const listeners = new Set<(event: MediaQueryListEvent) => void>();","newLineNumber":8},{"type":"add","content":"","newLineNumber":9},{"type":"add","content":" const mediaQueryList = {","newLineNumber":10},{"type":"add","content":" get matches() {","newLineNumber":11},{"type":"add","content":" return matches;","newLineNumber":12},{"type":"add","content":" },","newLineNumber":13},{"type":"add","content":" media: '(prefers-reduced-motion: reduce)',","newLineNumber":14},{"type":"add","content":" onchange: null,","newLineNumber":15},{"type":"add","content":" addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {","newLineNumber":16},{"type":"add","content":" if (type === 'change' && typeof listener === 'function') {","newLineNumber":17},{"type":"add","content":" listeners.add(listener as (event: MediaQueryListEvent) => void);","newLineNumber":18},{"type":"add","content":" }","newLineNumber":19},{"type":"add","content":" }),","newLineNumber":20},{"type":"add","content":" removeEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {","newLineNumber":21},{"type":"add","content":" if (type === 'change' && typeof listener === 'function') {","newLineNumber":22},{"type":"add","content":" listeners.delete(listener as (event: MediaQueryListEvent) => void);","newLineNumber":23},{"type":"add","content":" }","newLineNumber":24},{"type":"add","content":" }),","newLineNumber":25},{"type":"add","content":" addListener: vi.fn(),","newLineNumber":26},{"type":"add","content":" removeListener: vi.fn(),","newLineNumber":27},{"type":"add","content":" dispatchEvent: vi.fn(),","newLineNumber":28},{"type":"add","content":" } as MediaQueryList;","newLineNumber":29},{"type":"add","content":"","newLineNumber":30},{"type":"add","content":" Object.defineProperty(window, 'matchMedia', {","newLineNumber":31},{"type":"add","content":" configurable: true,","newLineNumber":32},{"type":"add","content":" writable: true,","newLineNumber":33},{"type":"add","content":" value: vi.fn(() => mediaQueryList),","newLineNumber":34},{"type":"add","content":" });","newLineNumber":35},{"type":"add","content":"","newLineNumber":36},{"type":"add","content":" return {","newLineNumber":37},{"type":"add","content":" setMatches(nextMatches: boolean) {","newLineNumber":38},{"type":"add","content":" matches = nextMatches;","newLineNumber":39},{"type":"add","content":" const event = { matches: nextMatches, media: mediaQueryList.media } as MediaQueryListEvent;","newLineNumber":40},{"type":"add","content":" listeners.forEach((listener) => listener(event));","newLineNumber":41},{"type":"add","content":" },","newLineNumber":42},{"type":"add","content":" };","newLineNumber":43},{"type":"add","content":"};","newLineNumber":44},{"type":"add","content":"","newLineNumber":45},{"type":"add","content":"describe('usePreferredScrollBehavior', () => {","newLineNumber":46},{"type":"add","content":" beforeEach(() => {","newLineNumber":47},{"type":"add","content":" setMatchMedia(false);","newLineNumber":48},{"type":"add","content":" });","newLineNumber":49},{"type":"add","content":"","newLineNumber":50},{"type":"add","content":" it(\"returns 'smooth' for setting 'enabled' even when OS prefers reduced motion\", () => {","newLineNumber":51},{"type":"add","content":" setMatchMedia(true);","newLineNumber":52},{"type":"add","content":" const { result } = renderHook(() => usePreferredScrollBehavior('enabled'));","newLineNumber":53},{"type":"add","content":" expect(result.current).toBe('smooth');","newLineNumber":54},{"type":"add","content":" });","newLineNumber":55},{"type":"add","content":"","newLineNumber":56},{"type":"add","content":" it(\"returns 'instant' for setting 'disabled' even when OS does not prefer reduced motion\", () => {","newLineNumber":57},{"type":"add","content":" setMatchMedia(false);","newLineNumber":58},{"type":"add","content":" const { result } = renderHook(() => usePreferredScrollBehavior('disabled'));","newLineNumber":59},{"type":"add","content":" expect(result.current).toBe('instant');","newLineNumber":60},{"type":"add","content":" });","newLineNumber":61},{"type":"add","content":"","newLineNumber":62},{"type":"add","content":" it(\"resolves 'auto' to 'smooth' when OS does not prefer reduced motion\", () => {","newLineNumber":63},{"type":"add","content":" setMatchMedia(false);","newLineNumber":64},{"type":"add","content":" const { result } = renderHook(() => usePreferredScrollBehavior('auto'));","newLineNumber":65},{"type":"add","content":" expect(result.current).toBe('smooth');","newLineNumber":66},{"type":"add","content":" });","newLineNumber":67},{"type":"add","content":"","newLineNumber":68},{"type":"add","content":" it(\"resolves 'auto' to 'instant' when OS prefers reduced motion\", () => {","newLineNumber":69},{"type":"add","content":" setMatchMedia(true);","newLineNumber":70},{"type":"add","content":" const { result } = renderHook(() => usePreferredScrollBehavior('auto'));","newLineNumber":71},{"type":"add","content":" expect(result.current).toBe('instant');","newLineNumber":72},{"type":"add","content":" });","newLineNumber":73},{"type":"add","content":"","newLineNumber":74},{"type":"add","content":" it(\"re-resolves 'auto' when OS preference toggles at runtime\", async () => {","newLineNumber":75},{"type":"add","content":" const matchMedia = setMatchMedia(false);","newLineNumber":76},{"type":"add","content":" const { result } = renderHook(() => usePreferredScrollBehavior('auto'));","newLineNumber":77},{"type":"add","content":" expect(result.current).toBe('smooth');","newLineNumber":78},{"type":"add","content":"","newLineNumber":79},{"type":"add","content":" act(() => {","newLineNumber":80},{"type":"add","content":" matchMedia.setMatches(true);","newLineNumber":81},{"type":"add","content":" });","newLineNumber":82},{"type":"add","content":"","newLineNumber":83},{"type":"add","content":" await waitFor(() => {","newLineNumber":84},{"type":"add","content":" expect(result.current).toBe('instant');","newLineNumber":85},{"type":"add","content":" });","newLineNumber":86},{"type":"add","content":"","newLineNumber":87},{"type":"add","content":" act(() => {","newLineNumber":88},{"type":"add","content":" matchMedia.setMatches(false);","newLineNumber":89},{"type":"add","content":" });","newLineNumber":90},{"type":"add","content":"","newLineNumber":91},{"type":"add","content":" await waitFor(() => {","newLineNumber":92},{"type":"add","content":" expect(result.current).toBe('smooth');","newLineNumber":93},{"type":"add","content":" });","newLineNumber":94},{"type":"add","content":" });","newLineNumber":95},{"type":"add","content":"});","newLineNumber":96}]}],"isGenerated":false},{"path":"src/client/hooks/usePreferredScrollBehavior.ts","status":"added","additions":31,"deletions":0,"chunks":[{"header":"@@ -0,0 +1,31 @@","oldStart":0,"oldLines":0,"newStart":1,"newLines":31,"lines":[{"type":"add","content":"import { useSyncExternalStore } from 'react';","newLineNumber":1},{"type":"add","content":"","newLineNumber":2},{"type":"add","content":"export type ScrollAnimationSetting = 'auto' | 'enabled' | 'disabled';","newLineNumber":3},{"type":"add","content":"","newLineNumber":4},{"type":"add","content":"const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';","newLineNumber":5},{"type":"add","content":"","newLineNumber":6},{"type":"add","content":"function subscribeReducedMotion(callback: () => void) {","newLineNumber":7},{"type":"add","content":" const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);","newLineNumber":8},{"type":"add","content":" mediaQuery.addEventListener('change', callback);","newLineNumber":9},{"type":"add","content":" return () => mediaQuery.removeEventListener('change', callback);","newLineNumber":10},{"type":"add","content":"}","newLineNumber":11},{"type":"add","content":"","newLineNumber":12},{"type":"add","content":"function getReducedMotionSnapshot(): boolean {","newLineNumber":13},{"type":"add","content":" return window.matchMedia(REDUCED_MOTION_QUERY).matches;","newLineNumber":14},{"type":"add","content":"}","newLineNumber":15},{"type":"add","content":"","newLineNumber":16},{"type":"add","content":"function getReducedMotionServerSnapshot(): boolean {","newLineNumber":17},{"type":"add","content":" return false;","newLineNumber":18},{"type":"add","content":"}","newLineNumber":19},{"type":"add","content":"","newLineNumber":20},{"type":"add","content":"export function usePreferredScrollBehavior(setting: ScrollAnimationSetting): ScrollBehavior {","newLineNumber":21},{"type":"add","content":" const systemPrefersReducedMotion = useSyncExternalStore(","newLineNumber":22},{"type":"add","content":" subscribeReducedMotion,","newLineNumber":23},{"type":"add","content":" getReducedMotionSnapshot,","newLineNumber":24},{"type":"add","content":" getReducedMotionServerSnapshot,","newLineNumber":25},{"type":"add","content":" );","newLineNumber":26},{"type":"add","content":"","newLineNumber":27},{"type":"add","content":" if (setting === 'enabled') return 'smooth';","newLineNumber":28},{"type":"add","content":" if (setting === 'disabled') return 'instant';","newLineNumber":29},{"type":"add","content":" return systemPrefersReducedMotion ? 'instant' : 'smooth';","newLineNumber":30},{"type":"add","content":"}","newLineNumber":31}]}],"isGenerated":false}],"isEmpty":false,"baseCommitish":"7d40fd4","targetCommitish":"a72112f","requestedBaseCommitish":"7d40fd4","requestedTargetCommitish":"a72112f","ignoreWhitespace":true,"mode":"split","repositoryId":"site-demo:7d40fd4...a72112f-comments"},"blobs":{"7d40fd4:src/client/App.tsx":"import { Columns, AlignLeft, Settings, PanelLeftClose, PanelLeft, Keyboard } from 'lucide-react';\nimport { useState, useEffect, useCallback, useRef, useMemo } from 'react';\n\nimport {\n type DiffCommentThread,\n type DiffResponse,\n type DiffSelection,\n type DiffViewMode,\n type DiffSide,\n type LineNumber,\n type CommentThread,\n type RevisionsResponse,\n} from '../types/diff';\nimport { DEFAULT_DIFF_VIEW_MODE, normalizeDiffViewMode } from '../utils/diffMode';\nimport { mergeCommentThreads } from '../utils/commentImports';\nimport {\n createDiffSelection,\n diffSelectionsEqual,\n getDiffSelectionKey,\n normalizeBaseMode,\n} from '../utils/diffSelection';\n\nimport { Checkbox } from './components/Checkbox';\nimport { CommentsDropdown } from './components/CommentsDropdown';\nimport { CommentsListModal } from './components/CommentsListModal';\nimport { DiffQuickMenu } from './components/DiffQuickMenu';\nimport { DiffViewer } from './components/DiffViewer';\nimport { FileList } from './components/FileList';\nimport { GitHubIcon } from './components/GitHubIcon';\nimport { HelpModal } from './components/HelpModal';\nimport { Logo } from './components/Logo';\nimport { ReloadButton } from './components/ReloadButton';\nimport { RevisionDetailModal } from './components/RevisionDetailModal';\nimport { SettingsModal } from './components/SettingsModal';\nimport { SparkleAnimation } from './components/SparkleAnimation';\nimport { WordHighlightProvider } from './contexts/WordHighlightContext';\nimport { useAppearanceSettings } from './hooks/useAppearanceSettings';\nimport { useDiffComments } from './hooks/useDiffComments';\nimport { useExpandedLines, type MergedChunk } from './hooks/useExpandedLines';\nimport { useFileWatch } from './hooks/useFileWatch';\nimport { useKeyboardNavigation } from './hooks/useKeyboardNavigation';\nimport { useLazyDiffRendering } from './hooks/useLazyDiffRendering';\nimport { useViewedFiles } from './hooks/useViewedFiles';\nimport { useViewport } from './hooks/useViewport';\nimport { hasMultipleCommentAuthors } from './utils/commentAuthors';\nimport { copyTextToClipboard } from './utils/clipboard';\nimport { getFileElementId } from './utils/domUtils';\nimport { findCommentPosition } from './utils/navigation/positionHelpers';\nimport { resolveEventSourceUrl } from './utils/eventSourceUrl';\nimport {\n EMPTY_MERGED_CHUNKS_STATE,\n buildMergedChunksState,\n getMergedChunksForVersion,\n} from './utils/mergedChunks';\n\nconst EMPTY_COMMENT_THREADS: CommentThread[] = [];\nconst EMPTY_MERGED_CHUNKS: MergedChunk[] = [];\nconst SIDEBAR_WIDTH_STORAGE_KEY = 'difit.sidebarWidth';\nconst SIDEBAR_OPEN_STORAGE_KEY = 'difit.sidebarOpen';\nconst SIDEBAR_MIN_WIDTH = 200;\nconst SIDEBAR_MAX_WIDTH = 600;\nconst SIDEBAR_DEFAULT_WIDTH = 280;\n\nconst getInitialSidebarWidth = () => {\n if (typeof window === 'undefined') {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n const stored = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);\n if (!stored) {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n const parsed = Number.parseInt(stored, 10);\n if (!Number.isFinite(parsed)) {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, parsed));\n};\n\nconst getInitialFileTreeOpen = () => {\n if (typeof window === 'undefined') {\n return true;\n }\n const stored = window.localStorage.getItem(SIDEBAR_OPEN_STORAGE_KEY);\n if (stored === null) {\n return true;\n }\n if (stored === 'true') {\n return true;\n }\n if (stored === 'false') {\n return false;\n }\n return true;\n};\n\nfunction App() {\n const [diffData, setDiffData] = useState<DiffResponse | null>(null);\n const [diffDataVersion, setDiffDataVersion] = useState(0);\n const [diffMode, setDiffMode] = useState<DiffViewMode>(DEFAULT_DIFF_VIEW_MODE);\n const hasUserSetDiffModeRef = useRef(false);\n const [ignoreWhitespace, setIgnoreWhitespace] = useState(true);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [isCopiedAll, setIsCopiedAll] = useState(false);\n const [sidebarWidth, setSidebarWidth] = useState(getInitialSidebarWidth);\n const [isSettingsOpen, setIsSettingsOpen] = useState(false);\n const [isFileTreeOpen, setIsFileTreeOpen] = useState(getInitialFileTreeOpen);\n const [isDragging, setIsDragging] = useState(false);\n const [showSparkles, setShowSparkles] = useState(false);\n const [hasTriggeredSparkles, setHasTriggeredSparkles] = useState(false);\n const [isCommentsListOpen, setIsCommentsListOpen] = useState(false);\n const [isRevisionModalOpen, setIsRevisionModalOpen] = useState(false);\n const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());\n const collapsedInitializedRef = useRef(false);\n const diffScrollContainerRef = useRef<HTMLElement | null>(null);\n\n // Revision selector state\n const [revisionOptions, setRevisionOptions] = useState<RevisionsResponse | null>(null);\n const [selectedRevision, setSelectedRevision] = useState<DiffSelection>(\n createDiffSelection('', ''),\n );\n const [resolvedBaseRevision, setResolvedBaseRevision] = useState<string>('');\n const [resolvedTargetRevision, setResolvedTargetRevision] = useState<string>('');\n const hasUserSelectedRevisionRef = useRef(false);\n const currentRequestedBaseModeRef = useRef(selectedRevision.baseMode);\n currentRequestedBaseModeRef.current = diffData?.requestedBaseMode ?? selectedRevision.baseMode;\n const selectedRevisionRef = useRef(selectedRevision);\n selectedRevisionRef.current = selectedRevision;\n const diffRequestIdRef = useRef(0);\n const activeDiffAbortControllerRef = useRef<AbortController | null>(null);\n const resolvedSelection = useMemo<DiffSelection | null>(() => {\n if (!diffData?.baseCommitish || !diffData?.targetCommitish) {\n return null;\n }\n\n return createDiffSelection(\n diffData.baseCommitish,\n diffData.targetCommitish,\n diffData.requestedBaseMode,\n );\n }, [diffData]);\n const resolvedSelectionKey = useMemo(() => {\n if (!resolvedSelection) {\n return null;\n }\n\n return getDiffSelectionKey(resolvedSelection);\n }, [resolvedSelection]);\n\n const { settings, updateSettings } = useAppearanceSettings();\n const { isMobile, isDesktop } = useViewport();\n\n // New diff-aware comment system\n const {\n hasLoadedComments,\n threads,\n replaceThreads,\n addThread,\n replyToThread,\n removeThread,\n removeMessage,\n updateMessage,\n clearAllComments,\n generateThreadPrompt,\n generateAllCommentsPrompt,\n } = useDiffComments(\n resolvedSelection?.baseCommitish,\n resolvedSelection?.targetCommitish,\n diffData?.commit, // Using commit as currentCommitHash\n undefined, // branchToHash map - could be populated from server data\n diffData?.repositoryId, // Repository identifier for storage isolation\n resolvedSelection?.baseMode,\n );\n\n const normalizedThreads = useMemo<CommentThread[]>(\n () =>\n threads.map((thread) => ({\n id: thread.id,\n file: thread.filePath,\n line:\n typeof thread.position.line === 'number'\n ? thread.position.line\n : ([thread.position.line.start, thread.position.line.end] as [number, number]),\n side: thread.position.side,\n createdAt: thread.createdAt,\n updatedAt: thread.updatedAt,\n codeContent: thread.codeSnapshot?.content,\n messages: thread.messages,\n })),\n [threads],\n );\n const showAuthorBadges = useMemo(\n () => hasMultipleCommentAuthors(normalizedThreads.flatMap((thread) => thread.messages)),\n [normalizedThreads],\n );\n\n const threadsByFile = useMemo(() => {\n const map = new Map<string, CommentThread[]>();\n normalizedThreads.forEach((thread) => {\n const entry = map.get(thread.file);\n if (entry) {\n entry.push(thread);\n } else {\n map.set(thread.file, [thread]);\n }\n });\n return map;\n }, [normalizedThreads]);\n const showMobileCommentsBar = isMobile && threads.length > 0;\n const commentsContextKey = useMemo(() => {\n if (!resolvedSelectionKey) {\n return null;\n }\n\n return `${diffData?.repositoryId ?? 'default'}:${resolvedSelectionKey}`;\n }, [diffData?.repositoryId, resolvedSelectionKey]);\n const commentSessionQueryString = useMemo(() => {\n if (!resolvedSelection) {\n return null;\n }\n\n const params = new URLSearchParams({\n base: resolvedSelection.baseCommitish,\n target: resolvedSelection.targetCommitish,\n });\n if (resolvedSelection.baseMode === 'merge-base') {\n params.set('baseMode', resolvedSelection.baseMode);\n }\n\n return params.toString();\n }, [resolvedSelection]);\n const getCommentApiUrl = useCallback(\n (path: string) => {\n if (!commentSessionQueryString) {\n return path;\n }\n return `${path}?${commentSessionQueryString}`;\n },\n [commentSessionQueryString],\n );\n const [bootstrappedCommentsKey, setBootstrappedCommentsKey] = useState<string | null>(null);\n const hasBootstrappedComments =\n commentsContextKey !== null && commentsContextKey === bootstrappedCommentsKey;\n const bootstrappingCommentsKeyRef = useRef<string | null>(null);\n const skipNextCommentSyncRef = useRef(false);\n const pendingBootstrapAfterLocalResetRef = useRef(false);\n\n useEffect(() => {\n if (commentsContextKey !== bootstrappedCommentsKey) {\n skipNextCommentSyncRef.current = false;\n }\n }, [bootstrappedCommentsKey, commentsContextKey]);\n\n const fetchServerThreads = useCallback(async (): Promise<DiffCommentThread[]> => {\n const response = await fetch(getCommentApiUrl('/api/comments-json'));\n if (!response.ok) {\n throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`);\n }\n\n const payload = (await response.json()) as { threads?: DiffCommentThread[] };\n return Array.isArray(payload.threads) ? payload.threads : [];\n }, [getCommentApiUrl]);\n\n const syncThreadsToServer = useCallback(\n async (nextThreads: DiffCommentThread[]) => {\n await fetch(getCommentApiUrl('/api/comments'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ threads: nextThreads }),\n });\n },\n [getCommentApiUrl],\n );\n\n // Viewed files management\n const { viewedFiles, hasLoadedInitialViewedFiles, toggleFileViewed, clearViewedFiles } =\n useViewedFiles(\n resolvedSelection?.baseCommitish,\n resolvedSelection?.targetCommitish,\n diffData?.commit,\n undefined,\n diffData?.files,\n diffData?.repositoryId, // Repository identifier for storage isolation\n settings.autoViewedPatterns,\n resolvedSelection?.baseMode,\n );\n\n // Reset initialization flag when diff context changes\n useEffect(() => {\n collapsedInitializedRef.current = false;\n }, [diffData?.repositoryId, resolvedSelectionKey, diffData?.commit]);\n\n // Initialize collapsed files from viewed files (only once per diff)\n useEffect(() => {\n if (!collapsedInitializedRef.current && hasLoadedInitialViewedFiles) {\n setCollapsedFiles(new Set(viewedFiles));\n collapsedInitializedRef.current = true;\n }\n }, [viewedFiles, hasLoadedInitialViewedFiles]);\n const {\n renderedFilePaths,\n ensureFileRendered,\n ensureFilesRenderedUpTo,\n registerLazyFileContainer,\n scrollFileIntoDiffContainer,\n } = useLazyDiffRendering({\n diffData,\n diffScrollContainerRef,\n setDiffData,\n });\n\n const toggleFileReviewed = useCallback(\n async (filePath: string) => {\n if (!diffData) return;\n\n const file = diffData.files.find((f) => f.path === filePath);\n if (!file) return;\n\n const wasViewed = viewedFiles.has(filePath);\n await toggleFileViewed(filePath, file);\n\n // Update collapsed state based on viewed state\n setCollapsedFiles((prev) => {\n const newSet = new Set(prev);\n if (!wasViewed) {\n // Marking as viewed -> collapse the file\n newSet.add(filePath);\n } else {\n // Marking as not viewed -> expand the file\n newSet.delete(filePath);\n }\n return newSet;\n });\n\n // When marking as reviewed (closing file), scroll to the file header\n if (!wasViewed) {\n setTimeout(() => {\n scrollFileIntoDiffContainer(filePath);\n }, 100);\n }\n },\n [diffData, scrollFileIntoDiffContainer, toggleFileViewed, viewedFiles],\n );\n\n const toggleFileCollapsed = useCallback((filePath: string) => {\n setCollapsedFiles((prev) => {\n const newSet = new Set(prev);\n if (newSet.has(filePath)) {\n newSet.delete(filePath);\n } else {\n newSet.add(filePath);\n }\n return newSet;\n });\n }, []);\n\n const toggleAllFilesCollapsed = useCallback(\n (shouldCollapse: boolean) => {\n if (!diffData) return;\n\n if (shouldCollapse) {\n // Collapse all files\n setCollapsedFiles(new Set(diffData.files.map((f) => f.path)));\n } else {\n // Expand all files\n setCollapsedFiles(new Set());\n }\n },\n [diffData],\n );\n\n const handleMobileFileSelected = useCallback(() => {\n setIsFileTreeOpen(false);\n }, []);\n\n const handleDiffModeChange = useCallback((mode: DiffViewMode) => {\n hasUserSetDiffModeRef.current = true;\n setDiffMode(mode);\n }, []);\n\n // Lift expand state to App level so navigation and rendering share the same merged chunks\n const {\n isLoading: isExpandLoading,\n expandLines,\n expandAllBetweenChunks,\n prefetchFileContent,\n getMergedChunks,\n lastUpdatedAt,\n } = useExpandedLines({\n baseCommitish: diffData?.baseCommitish,\n targetCommitish: diffData?.targetCommitish,\n diffIdentity: diffDataVersion,\n });\n\n const getMergedChunksRef = useRef(getMergedChunks);\n useEffect(() => {\n getMergedChunksRef.current = getMergedChunks;\n }, [getMergedChunks]);\n\n const [mergedChunksState, setMergedChunksState] = useState(EMPTY_MERGED_CHUNKS_STATE);\n const filesByPath = useMemo(() => {\n const map = new Map<string, DiffResponse['files'][number]>();\n diffData?.files.forEach((file) => {\n map.set(file.path, file);\n });\n return map;\n }, [diffData]);\n\n // Recompute merged chunks for the current fetched diff only.\n useEffect(() => {\n if (!diffData) {\n setMergedChunksState(EMPTY_MERGED_CHUNKS_STATE);\n return;\n }\n\n setMergedChunksState(\n buildMergedChunksState(diffDataVersion, renderedFilePaths, filesByPath, (file) =>\n getMergedChunksRef.current(file),\n ),\n );\n }, [diffData, diffDataVersion, filesByPath, renderedFilePaths, lastUpdatedAt]);\n\n // Create files with merged chunks for keyboard navigation\n const navigableFiles = useMemo(() => {\n if (!diffData) return [];\n return diffData.files.map((file) => ({\n ...file,\n chunks:\n getMergedChunksForVersion(mergedChunksState, diffDataVersion, file.path) || file.chunks,\n }));\n }, [diffData, diffDataVersion, mergedChunksState]);\n\n // State to trigger comment creation from keyboard\n const [commentTrigger, setCommentTrigger] = useState<{\n fileIndex: number;\n chunkIndex: number;\n lineIndex: number;\n } | null>(null);\n const fetchDiffDataRef = useRef<((selection?: DiffSelection) => Promise<void>) | null>(null);\n const handleWatchReload = useCallback(async () => {\n await fetchDiffDataRef.current?.();\n }, []);\n const handleCommentsChanged = useCallback(async () => {\n try {\n const serverThreads = await fetchServerThreads();\n skipNextCommentSyncRef.current = true;\n replaceThreads(serverThreads);\n if (commentsContextKey) {\n setBootstrappedCommentsKey(commentsContextKey);\n }\n } catch (commentsError) {\n console.error('Failed to refresh comments from server:', commentsError);\n }\n }, [commentsContextKey, fetchServerThreads, replaceThreads]);\n\n // File watch for reload functionality - initialize with callback\n const { shouldReload, reload, watchState } = useFileWatch(\n handleWatchReload,\n handleCommentsChanged,\n );\n\n const { cursor, isHelpOpen, setIsHelpOpen, setCursorPosition } = useKeyboardNavigation({\n files: navigableFiles,\n comments: normalizedThreads,\n viewMode: diffMode,\n reviewedFiles: viewedFiles,\n onToggleReviewed: toggleFileReviewed,\n onCreateComment: () => {\n if (cursor) {\n setCommentTrigger({\n fileIndex: cursor.fileIndex,\n chunkIndex: cursor.chunkIndex,\n lineIndex: cursor.lineIndex,\n });\n }\n },\n onCopyAllComments: () => {\n if (threads.length > 0) {\n void handleCopyAllComments();\n }\n },\n onDeleteAllComments: () => {\n if (threads.length > 0 && confirm('Delete all comments?')) {\n clearAllComments();\n }\n },\n onShowCommentsList: () => {\n setIsCommentsListOpen(true);\n },\n onRefresh: () => {\n reload();\n },\n });\n\n useEffect(() => {\n if (!diffData || !cursor) return;\n\n const filePath = diffData.files[cursor.fileIndex]?.path;\n if (!filePath || renderedFilePaths.has(filePath)) return;\n\n ensureFilesRenderedUpTo(filePath);\n requestAnimationFrame(() => {\n setCursorPosition(cursor);\n });\n }, [cursor, diffData, ensureFilesRenderedUpTo, renderedFilePaths, setCursorPosition]);\n\n const handleLineClick = useCallback(\n (fileIndex: number, chunkIndex: number, lineIndex: number, side: 'left' | 'right') => {\n setCursorPosition({\n fileIndex,\n chunkIndex,\n lineIndex,\n side,\n });\n },\n [setCursorPosition],\n );\n\n const handleCommentTriggerHandled = useCallback(() => {\n setCommentTrigger(null);\n }, [setCommentTrigger]);\n\n const handleGenerateThreadPrompt = useCallback(\n (thread: CommentThread) => generateThreadPrompt(thread.id),\n [generateThreadPrompt],\n );\n\n const handleMouseDown = (e: React.MouseEvent) => {\n e.preventDefault();\n setIsDragging(true);\n const startX = e.clientX;\n const startWidth = sidebarWidth;\n\n const handleMouseMove = (e: MouseEvent) => {\n const newWidth = Math.max(\n SIDEBAR_MIN_WIDTH,\n Math.min(SIDEBAR_MAX_WIDTH, startWidth + (e.clientX - startX)),\n );\n setSidebarWidth(newWidth);\n };\n\n const handleMouseUp = () => {\n setIsDragging(false);\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n };\n\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n };\n\n const fetchDiffData = useCallback(\n async (selection?: DiffSelection) => {\n const requestId = diffRequestIdRef.current + 1;\n diffRequestIdRef.current = requestId;\n activeDiffAbortControllerRef.current?.abort();\n const controller = new AbortController();\n activeDiffAbortControllerRef.current = controller;\n try {\n const requestedSelection =\n selection ??\n (hasUserSelectedRevisionRef.current ? selectedRevisionRef.current : undefined);\n const params = new URLSearchParams({\n ignoreWhitespace: String(ignoreWhitespace),\n });\n if (requestedSelection?.baseCommitish) params.set('base', requestedSelection.baseCommitish);\n if (requestedSelection?.targetCommitish)\n params.set('target', requestedSelection.targetCommitish);\n if (requestedSelection?.baseMode === 'merge-base')\n params.set('baseMode', requestedSelection.baseMode);\n\n const response = await fetch(`/api/diff?${params}`, {\n signal: controller.signal,\n });\n if (!response.ok) throw new Error('Failed to fetch diff data');\n const data = (await response.json()) as DiffResponse;\n if (diffRequestIdRef.current !== requestId) {\n return;\n }\n setDiffData(data);\n setDiffDataVersion((prev) => prev + 1);\n\n // Update resolved revision state from server response\n setResolvedBaseRevision(\n data.baseCommitish && data.requestedBaseMode !== 'merge-base' ? data.baseCommitish : '',\n );\n if (data.targetCommitish) setResolvedTargetRevision(data.targetCommitish);\n\n if (!hasUserSelectedRevisionRef.current) {\n const requestedBase = data.requestedBaseCommitish ?? data.baseCommitish;\n const requestedTarget = data.requestedTargetCommitish ?? data.targetCommitish;\n if (requestedBase && requestedTarget) {\n setSelectedRevision(\n createDiffSelection(requestedBase, requestedTarget, data.requestedBaseMode),\n );\n }\n }\n\n // Set diff mode from server response if provided\n if (data.mode && !hasUserSetDiffModeRef.current) {\n setDiffMode(normalizeDiffViewMode(data.mode));\n }\n\n // Lock files are now automatically marked as viewed by useViewedFiles hook\n } catch (err) {\n if ((err as { name?: string } | null)?.name === 'AbortError') {\n return;\n }\n if (diffRequestIdRef.current !== requestId) {\n return;\n }\n setError(err instanceof Error ? err.message : 'Unknown error');\n } finally {\n if (activeDiffAbortControllerRef.current === controller) {\n activeDiffAbortControllerRef.current = null;\n }\n if (diffRequestIdRef.current === requestId) {\n setLoading(false);\n }\n }\n },\n [ignoreWhitespace],\n );\n fetchDiffDataRef.current = fetchDiffData;\n\n useEffect(() => {\n void fetchDiffData();\n }, [fetchDiffData]);\n\n useEffect(() => {\n return () => {\n activeDiffAbortControllerRef.current?.abort();\n };\n }, []);\n\n useEffect(() => {\n if (isMobile && diffMode !== 'unified') {\n setDiffMode('unified');\n }\n }, [diffMode, isMobile]);\n\n useEffect(() => {\n try {\n window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth));\n } catch {\n // Ignore localStorage errors (e.g. disabled storage).\n }\n }, [sidebarWidth]);\n\n useEffect(() => {\n try {\n window.localStorage.setItem(SIDEBAR_OPEN_STORAGE_KEY, String(isFileTreeOpen));\n } catch {\n // Ignore localStorage errors (e.g. disabled storage).\n }\n }, [isFileTreeOpen]);\n\n // Fetch revision options on mount\n useEffect(() => {\n fetch('/api/revisions')\n .then((res) => (res.ok ? res.json() : null))\n .then((data: RevisionsResponse | null) => {\n setRevisionOptions(data);\n if (\n data?.resolvedBase &&\n normalizeBaseMode(currentRequestedBaseModeRef.current) !== 'merge-base'\n ) {\n setResolvedBaseRevision((prev) => prev || data.resolvedBase || '');\n }\n if (data?.resolvedTarget) {\n setResolvedTargetRevision((prev) => prev || data.resolvedTarget || '');\n }\n })\n .catch(() => setRevisionOptions(null));\n }, []);\n\n // Handle revision change\n const handleRevisionChange = useCallback(\n async (nextSelection: DiffSelection) => {\n // Skip if no actual change\n if (diffSelectionsEqual(nextSelection, selectedRevision)) return;\n\n hasUserSelectedRevisionRef.current = true;\n selectedRevisionRef.current = nextSelection;\n setSelectedRevision(nextSelection);\n setLoading(true);\n setError(null);\n await fetchDiffData(nextSelection);\n },\n [fetchDiffData, selectedRevision],\n );\n\n // Clear comments and viewed files on initial load if requested via CLI flag\n const hasCleanedRef = useRef(false);\n useEffect(() => {\n if (diffData?.clearComments && !hasCleanedRef.current) {\n hasCleanedRef.current = true;\n pendingBootstrapAfterLocalResetRef.current = true;\n clearAllComments({ resetAppliedCommentImportIds: true });\n clearViewedFiles();\n console.log(\n '✅ All existing comments and viewed files cleared as requested via --clean flag',\n );\n }\n }, [diffData?.clearComments, clearAllComments, clearViewedFiles]);\n\n useEffect(() => {\n if (!commentsContextKey || !hasLoadedComments) {\n return;\n }\n\n if (bootstrappedCommentsKey === commentsContextKey) {\n return;\n }\n\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n return;\n }\n\n if (pendingBootstrapAfterLocalResetRef.current) {\n pendingBootstrapAfterLocalResetRef.current = false;\n return;\n }\n\n bootstrappingCommentsKeyRef.current = commentsContextKey;\n let cancelled = false;\n\n const bootstrapComments = async () => {\n try {\n const serverThreads = await fetchServerThreads();\n const mergedThreads = mergeCommentThreads(serverThreads, threads).threads;\n if (cancelled) {\n return;\n }\n\n skipNextCommentSyncRef.current = true;\n replaceThreads(mergedThreads);\n\n if (JSON.stringify(serverThreads) !== JSON.stringify(mergedThreads)) {\n await syncThreadsToServer(mergedThreads);\n }\n } catch (commentsError) {\n if (!cancelled) {\n console.error('Failed to bootstrap comments from server:', commentsError);\n }\n } finally {\n if (!cancelled) {\n setBootstrappedCommentsKey(commentsContextKey);\n }\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n bootstrappingCommentsKeyRef.current = null;\n }\n }\n };\n\n void bootstrapComments();\n\n return () => {\n cancelled = true;\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n bootstrappingCommentsKeyRef.current = null;\n }\n };\n }, [\n bootstrappedCommentsKey,\n commentsContextKey,\n fetchServerThreads,\n hasLoadedComments,\n replaceThreads,\n syncThreadsToServer,\n threads,\n ]);\n\n // Trigger sparkle animation when all files are viewed\n useEffect(() => {\n if (diffData) {\n // Reset the trigger flag when not all files are viewed\n if (viewedFiles.size < diffData.files.length) {\n setHasTriggeredSparkles(false);\n }\n // Show sparkles when all files are viewed and not already triggered\n else if (viewedFiles.size === diffData.files.length && !hasTriggeredSparkles) {\n setShowSparkles(true);\n setHasTriggeredSparkles(true);\n // Hide sparkles after animation completes\n setTimeout(() => {\n setShowSparkles(false);\n }, 1000);\n }\n }\n }, [viewedFiles.size, diffData, hasTriggeredSparkles]);\n\n // Send comments to server whenever they change and before page unload\n useEffect(() => {\n if (!hasBootstrappedComments) {\n return;\n }\n\n const data = JSON.stringify({ threads });\n const commentsApiUrl = getCommentApiUrl('/api/comments');\n\n // Also handle page unload\n const sendCommentsBeforeUnload = () => {\n // Use sendBeacon for reliable delivery during page unload, including empty states.\n navigator.sendBeacon(commentsApiUrl, data);\n };\n\n window.addEventListener('beforeunload', sendCommentsBeforeUnload);\n\n if (skipNextCommentSyncRef.current) {\n skipNextCommentSyncRef.current = false;\n return () => {\n window.removeEventListener('beforeunload', sendCommentsBeforeUnload);\n };\n }\n\n syncThreadsToServer(threads).catch((syncError) => {\n console.error('Failed to sync comments:', syncError);\n });\n\n return () => {\n window.removeEventListener('beforeunload', sendCommentsBeforeUnload);\n };\n }, [getCommentApiUrl, hasBootstrappedComments, syncThreadsToServer, threads]);\n\n // Establish SSE connection for tab close detection\n useEffect(() => {\n const eventSource = new EventSource(resolveEventSourceUrl('/api/heartbeat'));\n\n eventSource.onopen = () => {\n console.log('Connected to server heartbeat');\n };\n\n eventSource.onerror = () => {\n console.log('Server connection lost');\n eventSource.close();\n };\n\n // Cleanup on unmount\n return () => {\n eventSource.close();\n };\n }, []);\n\n const handleAddComment = useCallback(\n (\n file: string,\n line: LineNumber,\n body: string,\n codeContent?: string,\n side?: DiffSide,\n ): Promise<void> => {\n addThread({\n filePath: file,\n body,\n side: side || 'new',\n line: typeof line === 'number' ? line : { start: line[0], end: line[1] },\n codeSnapshot: codeContent\n ? {\n content: codeContent,\n language: undefined,\n }\n : undefined,\n });\n return Promise.resolve();\n },\n [addThread],\n );\n\n const handleCopyAllComments = async () => {\n try {\n const prompt = generateAllCommentsPrompt();\n await copyTextToClipboard(prompt);\n setIsCopiedAll(true);\n setTimeout(() => setIsCopiedAll(false), 2000);\n } catch (error) {\n console.error('Failed to copy all comments prompt:', error);\n }\n };\n\n const handleReplyToThread = useCallback(\n (threadId: string, body: string): Promise<void> => {\n replyToThread({ threadId, body });\n return Promise.resolve();\n },\n [replyToThread],\n );\n\n const handleNavigateToComment = (thread: CommentThread) => {\n if (!diffData) return;\n\n const position = findCommentPosition(thread, diffData.files);\n if (position) {\n setCursorPosition(position);\n }\n };\n\n const handleOpenInEditor = useCallback(\n async (filePath: string, lineNumber: number) => {\n try {\n const response = await fetch('/api/open-in-editor', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filePath, line: lineNumber, editor: settings.editor }),\n });\n\n if (!response.ok) {\n const payload: unknown = await response.json().catch(() => null);\n let message = response.statusText;\n if (\n payload &&\n typeof payload === 'object' &&\n 'error' in payload &&\n typeof (payload as { error?: unknown }).error === 'string'\n ) {\n message = (payload as { error: string }).error;\n }\n console.error('Failed to open file in editor:', message);\n }\n } catch (error) {\n console.error('Failed to open file in editor:', error);\n }\n },\n [settings.editor],\n );\n\n const handleGlobalClick = (e: React.MouseEvent) => {\n // Clear cursor position\n setCursorPosition(null);\n\n // Check if clicking on a comment button\n const target = e.target as HTMLElement;\n const isCommentButton = target.closest('[data-comment-button=\"true\"]');\n const isOpenInEditorButton = target.closest('[data-open-in-editor-button=\"true\"]');\n\n // Close empty comment forms (unless clicking on a comment button)\n if (!isCommentButton && !isOpenInEditorButton) {\n closeEmptyCommentForms(e);\n }\n };\n\n const closeEmptyCommentForms = (e: React.MouseEvent) => {\n const emptyForms = document.querySelectorAll('form[data-empty=\"true\"]');\n emptyForms.forEach((form) => {\n // Don't close if clicking inside the form itself\n if (!form.contains(e.target as Node)) {\n const cancelButton = form.querySelector('button[type=\"button\"]') as HTMLButtonElement;\n cancelButton?.click();\n }\n });\n };\n\n if (loading) {\n return (\n <div className=\"flex items-center justify-center h-screen bg-github-bg-primary\">\n <div className=\"text-github-text-secondary text-base\">Loading diff...</div>\n </div>\n );\n }\n\n if (error) {\n return (\n <div className=\"flex flex-col items-center justify-center h-screen bg-github-bg-primary text-center gap-2\">\n <h2 className=\"text-github-danger text-2xl mb-2\">Error</h2>\n <p className=\"text-github-text-secondary text-base\">{error}</p>\n </div>\n );\n }\n\n if (!diffData) {\n return (\n <div className=\"flex flex-col items-center justify-center h-screen bg-github-bg-primary text-center gap-2\">\n <h2 className=\"text-github-danger text-2xl mb-2\">No data</h2>\n <p className=\"text-github-text-secondary text-base\">No diff data available</p>\n </div>\n );\n }\n\n const canOpenInEditor = settings.editor !== 'none' && diffData.openInEditorAvailable !== false;\n\n return (\n <WordHighlightProvider>\n <div className=\"h-screen flex flex-col\" onClickCapture={handleGlobalClick}>\n <header\n className={`bg-github-bg-secondary border-b border-github-border flex ${\n isMobile ? 'flex-col' : 'flex-row items-center'\n }`}\n >\n <div\n className={`flex items-center justify-between w-full ${\n isMobile ? 'px-3 py-2 gap-3' : 'px-4 py-3 gap-4 w-auto'\n } ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isMobile ? '100%' : isFileTreeOpen ? `${sidebarWidth}px` : 'auto',\n minWidth: isMobile ? '0px' : isFileTreeOpen ? '200px' : 'auto',\n maxWidth: isMobile ? 'none' : isFileTreeOpen ? '600px' : 'none',\n }}\n >\n <h1>\n <Logo style={{ height: '18px', color: 'var(--color-github-text-secondary)' }} />\n </h1>\n <div className=\"flex items-center gap-1\">\n <button\n onClick={() => setIsFileTreeOpen(!isFileTreeOpen)}\n className=\"p-2 text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary rounded transition-colors\"\n title={isFileTreeOpen ? 'Collapse file tree' : 'Expand file tree'}\n aria-expanded={isFileTreeOpen}\n aria-controls=\"file-tree-panel\"\n aria-label=\"Toggle file tree panel\"\n >\n {isFileTreeOpen ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}\n </button>\n <button\n onClick={() => setIsSettingsOpen(true)}\n className=\"p-2 text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary rounded transition-colors\"\n title=\"Settings\"\n >\n <Settings size={18} />\n </button>\n </div>\n </div>\n {!isMobile && (\n <div\n className={`border-r border-github-border ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isFileTreeOpen ? '4px' : '0px',\n height: 'calc(100% - 16px)',\n margin: '8px 0',\n transform: 'translateX(-2px)',\n }}\n />\n )}\n <div\n className={`flex-1 flex flex-wrap items-center justify-between ${\n isMobile ? 'px-3 pb-2 gap-3' : 'px-4 py-3 gap-4'\n }`}\n >\n <div className={`flex flex-wrap items-center ${isMobile ? 'gap-2' : 'gap-3'}`}>\n {!isMobile && (\n <div className=\"flex bg-github-bg-tertiary border border-github-border rounded-md p-1\">\n <button\n onClick={() => handleDiffModeChange('split')}\n className={`px-3 py-1.5 text-xs font-medium rounded transition-all duration-200 flex items-center gap-1.5 cursor-pointer ${\n diffMode === 'split'\n ? 'bg-github-bg-primary text-github-text-primary shadow-sm'\n : 'text-github-text-secondary hover:text-github-text-primary'\n }`}\n >\n <Columns size={14} />\n Split\n </button>\n <button\n onClick={() => handleDiffModeChange('unified')}\n className={`px-3 py-1.5 text-xs font-medium rounded transition-all duration-200 flex items-center gap-1.5 cursor-pointer ${\n diffMode === 'unified'\n ? 'bg-github-bg-primary text-github-text-primary shadow-sm'\n : 'text-github-text-secondary hover:text-github-text-primary'\n }`}\n >\n <AlignLeft size={14} />\n Unified\n </button>\n </div>\n )}\n <Checkbox\n checked={ignoreWhitespace}\n onChange={setIgnoreWhitespace}\n label=\"Ignore Whitespace\"\n title={ignoreWhitespace ? 'Show whitespace changes' : 'Ignore whitespace changes'}\n />\n {/* File Watch Reload Button */}\n <ReloadButton\n shouldReload={shouldReload}\n isReloading={watchState.isReloading}\n onReload={reload}\n changeType={watchState.lastChangeType}\n compact={isMobile}\n />\n </div>\n <div\n className={`flex flex-wrap items-center text-sm text-github-text-secondary ${\n isMobile ? 'gap-3' : 'gap-4'\n }`}\n >\n {!isMobile && threads.length > 0 && (\n <CommentsDropdown\n commentsCount={threads.length}\n isCopiedAll={isCopiedAll}\n onCopyAll={handleCopyAllComments}\n onDeleteAll={clearAllComments}\n onViewAll={() => setIsCommentsListOpen(true)}\n />\n )}\n <div className=\"flex flex-col gap-1 items-center\">\n <div className=\"text-xs relative\">\n {viewedFiles.size === diffData.files.length\n ? 'All diffs difit-ed!'\n : `${viewedFiles.size} / ${diffData.files.length} files viewed`}\n <SparkleAnimation isActive={showSparkles} />\n </div>\n <div\n className=\"relative h-2 bg-github-bg-tertiary rounded-full overflow-hidden\"\n style={{\n width: '90px',\n border: '1px solid var(--color-github-border)',\n }}\n >\n <div\n className=\"absolute top-0 right-0 h-full transition-all duration-300 ease-out\"\n style={{\n width: `${((diffData.files.length - viewedFiles.size) / diffData.files.length) * 100}%`,\n backgroundColor: (() => {\n const remainingPercent =\n ((diffData.files.length - viewedFiles.size) / diffData.files.length) *\n 100;\n if (remainingPercent > 50) return 'var(--color-github-accent)'; // green\n if (remainingPercent > 20) return 'var(--color-github-warning)'; // yellow\n return 'var(--color-github-danger)'; // red\n })(),\n }}\n />\n </div>\n </div>\n {revisionOptions ? (\n <DiffQuickMenu\n options={revisionOptions}\n selection={selectedRevision}\n resolvedBaseRevision={resolvedBaseRevision}\n resolvedTargetRevision={resolvedTargetRevision}\n onSelectDiff={(selection) => void handleRevisionChange(selection)}\n onOpenAdvanced={() => setIsRevisionModalOpen(true)}\n compact={!isDesktop}\n />\n ) : (\n <span className=\"text-xs\">\n Reviewing:{' '}\n <code className=\"bg-github-bg-tertiary px-1.5 py-0.5 rounded text-xs text-github-text-primary\">\n {diffData.commit.includes('...') ? (\n <>\n <span className=\"text-github-text-secondary font-medium\">\n {diffData.commit.split('...')[0]}...\n </span>\n <span className=\"font-medium\">{diffData.commit.split('...')[1]}</span>\n </>\n ) : (\n diffData.commit\n )}\n </code>\n </span>\n )}\n </div>\n </div>\n </header>\n {revisionOptions && (\n <RevisionDetailModal\n key={isRevisionModalOpen ? getDiffSelectionKey(selectedRevision) : 'closed'}\n isOpen={isRevisionModalOpen}\n onClose={() => setIsRevisionModalOpen(false)}\n options={revisionOptions}\n selection={selectedRevision}\n resolvedBaseRevision={resolvedBaseRevision}\n resolvedTargetRevision={resolvedTargetRevision}\n onApply={(selection) => void handleRevisionChange(selection)}\n />\n )}\n\n {isMobile && isFileTreeOpen && (\n <button\n type=\"button\"\n aria-label=\"Close file tree\"\n className=\"fixed inset-0 bg-black/40 z-30\"\n onClick={() => setIsFileTreeOpen(false)}\n />\n )}\n\n <div className=\"flex flex-1 overflow-hidden relative\">\n <div\n className={`relative overflow-hidden ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isMobile ? '0px' : isFileTreeOpen ? `${sidebarWidth}px` : '0px',\n }}\n >\n <aside\n id=\"file-tree-panel\"\n className={`bg-github-bg-secondary overflow-y-auto flex flex-col ${\n isMobile\n ? 'fixed inset-y-0 right-0 z-40 w-[min(85vw,360px)] border-l border-github-border transition-transform duration-300 ease-out'\n : 'relative border-r border-github-border'\n }`}\n style={{\n width: isMobile ? 'min(85vw, 360px)' : `${sidebarWidth}px`,\n minWidth: isMobile ? '0px' : '200px',\n maxWidth: isMobile ? 'none' : '600px',\n height: '100%',\n transform: isMobile\n ? isFileTreeOpen\n ? 'translateX(0)'\n : 'translateX(100%)'\n : undefined,\n }}\n >\n <div className=\"flex-1 overflow-y-auto\">\n <FileList\n files={diffData.files}\n onScrollToFile={scrollFileIntoDiffContainer}\n onFileSelected={isMobile ? handleMobileFileSelected : undefined}\n comments={normalizedThreads}\n reviewedFiles={viewedFiles}\n onToggleReviewed={toggleFileReviewed}\n selectedFileIndex={cursor?.fileIndex ?? null}\n />\n </div>\n {!isMobile && (\n <div className=\"p-4 border-t border-github-border flex justify-between items-center\">\n <button\n onClick={() => setIsHelpOpen(true)}\n className=\"flex items-center gap-1.5 text-github-text-secondary hover:text-github-text-primary transition-colors\"\n title=\"Keyboard shortcuts (Shift+?)\"\n >\n <Keyboard size={16} />\n <span className=\"text-sm\">Shortcuts</span>\n </button>\n <a\n href=\"https://github.com/yoshiko-pg/difit\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 text-github-text-secondary hover:text-github-text-primary transition-colors\"\n title=\"View on GitHub\"\n >\n <span className=\"text-sm\">Star on GitHub</span>\n <GitHubIcon style={{ height: '18px', width: '18px' }} />\n </a>\n </div>\n )}\n </aside>\n </div>\n\n {!isMobile && (\n <div\n className={`bg-github-border hover:bg-github-text-muted cursor-col-resize ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isFileTreeOpen ? '4px' : '0px',\n }}\n onMouseDown={handleMouseDown}\n title=\"Drag to resize file list\"\n />\n )}\n\n <main\n ref={diffScrollContainerRef}\n className={`flex-1 overflow-y-auto ${showMobileCommentsBar ? 'pb-16' : ''}`}\n >\n {diffData.files.map((file, fileIndex) => {\n const fileThreads = threadsByFile.get(file.path) ?? EMPTY_COMMENT_THREADS;\n const mergedChunks =\n getMergedChunksForVersion(mergedChunksState, diffDataVersion, file.path) ??\n EMPTY_MERGED_CHUNKS;\n const isRendered = renderedFilePaths.has(file.path);\n return (\n <div\n key={file.path}\n id={getFileElementId(file.path)}\n data-file-path={file.path}\n data-rendered={isRendered ? 'true' : 'false'}\n ref={(node) => registerLazyFileContainer(file.path, node)}\n className=\"mb-6\"\n >\n {isRendered ? (\n <DiffViewer\n file={file}\n threads={fileThreads}\n showAuthorBadges={showAuthorBadges}\n diffMode={diffMode}\n reviewedFiles={viewedFiles}\n onToggleReviewed={toggleFileReviewed}\n collapsedFiles={collapsedFiles}\n onToggleCollapsed={toggleFileCollapsed}\n onToggleAllCollapsed={toggleAllFilesCollapsed}\n onAddComment={handleAddComment}\n onGenerateThreadPrompt={handleGenerateThreadPrompt}\n onRemoveThread={removeThread}\n onReplyToThread={handleReplyToThread}\n onRemoveMessage={removeMessage}\n onUpdateMessage={updateMessage}\n onOpenInEditor={canOpenInEditor ? handleOpenInEditor : undefined}\n syntaxTheme={settings.syntaxTheme}\n baseCommitish={diffData.baseCommitish}\n targetCommitish={diffData.targetCommitish}\n cursor={cursor?.fileIndex === fileIndex ? cursor : null}\n fileIndex={fileIndex}\n onLineClick={handleLineClick}\n commentTrigger={\n commentTrigger?.fileIndex === fileIndex ? commentTrigger : null\n }\n onCommentTriggerHandled={handleCommentTriggerHandled}\n mergedChunks={mergedChunks}\n expandLines={expandLines}\n expandAllBetweenChunks={expandAllBetweenChunks}\n prefetchFileContent={prefetchFileContent}\n isExpandLoading={isExpandLoading}\n />\n ) : (\n <div className=\"bg-github-bg-secondary border border-github-border rounded-md px-4 py-3\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"min-w-0\">\n <div className=\"text-xs uppercase tracking-wide text-github-text-muted\">\n Deferred Rendering\n </div>\n <div className=\"text-sm font-mono text-github-text-primary truncate\">\n {file.path}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => ensureFileRendered(file.path)}\n className=\"px-3 py-1.5 text-xs rounded border border-github-border text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary\"\n >\n Load now\n </button>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </main>\n </div>\n\n {showMobileCommentsBar && (\n <div className=\"fixed bottom-0 left-0 right-0 z-20 bg-github-bg-secondary border-t border-github-border px-4 py-2 flex justify-end\">\n <CommentsDropdown\n commentsCount={threads.length}\n isCopiedAll={isCopiedAll}\n onCopyAll={handleCopyAllComments}\n onDeleteAll={clearAllComments}\n onViewAll={() => setIsCommentsListOpen(true)}\n direction=\"up\"\n compact\n />\n </div>\n )}\n\n {isSettingsOpen && (\n <SettingsModal\n isOpen={isSettingsOpen}\n onClose={() => setIsSettingsOpen(false)}\n settings={settings}\n onSettingsChange={updateSettings}\n />\n )}\n\n <HelpModal isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />\n\n <CommentsListModal\n isOpen={isCommentsListOpen}\n onClose={() => setIsCommentsListOpen(false)}\n onNavigate={handleNavigateToComment}\n comments={normalizedThreads}\n showAuthorBadges={showAuthorBadges}\n onRemoveThread={removeThread}\n onGenerateThreadPrompt={handleGenerateThreadPrompt}\n onReplyToThread={handleReplyToThread}\n onRemoveMessage={removeMessage}\n onUpdateMessage={updateMessage}\n syntaxTheme={settings.syntaxTheme}\n />\n </div>\n </WordHighlightProvider>\n );\n}\n\nexport default App;\n","a72112f:src/client/App.tsx":"import { Columns, AlignLeft, Settings, PanelLeftClose, PanelLeft, Keyboard } from 'lucide-react';\nimport { useState, useEffect, useCallback, useRef, useMemo } from 'react';\n\nimport {\n type DiffCommentThread,\n type DiffResponse,\n type DiffSelection,\n type DiffViewMode,\n type DiffSide,\n type LineNumber,\n type CommentThread,\n type RevisionsResponse,\n} from '../types/diff';\nimport { DEFAULT_DIFF_VIEW_MODE, normalizeDiffViewMode } from '../utils/diffMode';\nimport { mergeCommentThreads } from '../utils/commentImports';\nimport {\n createDiffSelection,\n diffSelectionsEqual,\n getDiffSelectionKey,\n normalizeBaseMode,\n} from '../utils/diffSelection';\n\nimport { Checkbox } from './components/Checkbox';\nimport { CommentsDropdown } from './components/CommentsDropdown';\nimport { CommentsListModal } from './components/CommentsListModal';\nimport { DiffQuickMenu } from './components/DiffQuickMenu';\nimport { DiffViewer } from './components/DiffViewer';\nimport { FileList } from './components/FileList';\nimport { GitHubIcon } from './components/GitHubIcon';\nimport { HelpModal } from './components/HelpModal';\nimport { Logo } from './components/Logo';\nimport { ReloadButton } from './components/ReloadButton';\nimport { RevisionDetailModal } from './components/RevisionDetailModal';\nimport { SettingsModal } from './components/SettingsModal';\nimport { SparkleAnimation } from './components/SparkleAnimation';\nimport { WordHighlightProvider } from './contexts/WordHighlightContext';\nimport { useAppearanceSettings } from './hooks/useAppearanceSettings';\nimport { useDiffComments } from './hooks/useDiffComments';\nimport { useExpandedLines, type MergedChunk } from './hooks/useExpandedLines';\nimport { useFileWatch } from './hooks/useFileWatch';\nimport { useKeyboardNavigation } from './hooks/useKeyboardNavigation';\nimport { useLazyDiffRendering } from './hooks/useLazyDiffRendering';\nimport { useViewedFiles } from './hooks/useViewedFiles';\nimport { useViewport } from './hooks/useViewport';\nimport { hasMultipleCommentAuthors } from './utils/commentAuthors';\nimport { copyTextToClipboard } from './utils/clipboard';\nimport { getFileElementId } from './utils/domUtils';\nimport { findCommentPosition } from './utils/navigation/positionHelpers';\nimport { resolveEventSourceUrl } from './utils/eventSourceUrl';\nimport {\n EMPTY_MERGED_CHUNKS_STATE,\n buildMergedChunksState,\n getMergedChunksForVersion,\n} from './utils/mergedChunks';\n\nconst EMPTY_COMMENT_THREADS: CommentThread[] = [];\nconst EMPTY_MERGED_CHUNKS: MergedChunk[] = [];\nconst SIDEBAR_WIDTH_STORAGE_KEY = 'difit.sidebarWidth';\nconst SIDEBAR_OPEN_STORAGE_KEY = 'difit.sidebarOpen';\nconst SIDEBAR_MIN_WIDTH = 200;\nconst SIDEBAR_MAX_WIDTH = 600;\nconst SIDEBAR_DEFAULT_WIDTH = 280;\n\nconst getInitialSidebarWidth = () => {\n if (typeof window === 'undefined') {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n const stored = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);\n if (!stored) {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n const parsed = Number.parseInt(stored, 10);\n if (!Number.isFinite(parsed)) {\n return SIDEBAR_DEFAULT_WIDTH;\n }\n return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, parsed));\n};\n\nconst getInitialFileTreeOpen = () => {\n if (typeof window === 'undefined') {\n return true;\n }\n const stored = window.localStorage.getItem(SIDEBAR_OPEN_STORAGE_KEY);\n if (stored === null) {\n return true;\n }\n if (stored === 'true') {\n return true;\n }\n if (stored === 'false') {\n return false;\n }\n return true;\n};\n\nfunction App() {\n const [diffData, setDiffData] = useState<DiffResponse | null>(null);\n const [diffDataVersion, setDiffDataVersion] = useState(0);\n const [diffMode, setDiffMode] = useState<DiffViewMode>(DEFAULT_DIFF_VIEW_MODE);\n const hasUserSetDiffModeRef = useRef(false);\n const [ignoreWhitespace, setIgnoreWhitespace] = useState(true);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [isCopiedAll, setIsCopiedAll] = useState(false);\n const [sidebarWidth, setSidebarWidth] = useState(getInitialSidebarWidth);\n const [isSettingsOpen, setIsSettingsOpen] = useState(false);\n const [isFileTreeOpen, setIsFileTreeOpen] = useState(getInitialFileTreeOpen);\n const [isDragging, setIsDragging] = useState(false);\n const [showSparkles, setShowSparkles] = useState(false);\n const [hasTriggeredSparkles, setHasTriggeredSparkles] = useState(false);\n const [isCommentsListOpen, setIsCommentsListOpen] = useState(false);\n const [isRevisionModalOpen, setIsRevisionModalOpen] = useState(false);\n const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());\n const collapsedInitializedRef = useRef(false);\n const diffScrollContainerRef = useRef<HTMLElement | null>(null);\n\n // Revision selector state\n const [revisionOptions, setRevisionOptions] = useState<RevisionsResponse | null>(null);\n const [selectedRevision, setSelectedRevision] = useState<DiffSelection>(\n createDiffSelection('', ''),\n );\n const [resolvedBaseRevision, setResolvedBaseRevision] = useState<string>('');\n const [resolvedTargetRevision, setResolvedTargetRevision] = useState<string>('');\n const hasUserSelectedRevisionRef = useRef(false);\n const currentRequestedBaseModeRef = useRef(selectedRevision.baseMode);\n currentRequestedBaseModeRef.current = diffData?.requestedBaseMode ?? selectedRevision.baseMode;\n const selectedRevisionRef = useRef(selectedRevision);\n selectedRevisionRef.current = selectedRevision;\n const diffRequestIdRef = useRef(0);\n const activeDiffAbortControllerRef = useRef<AbortController | null>(null);\n const resolvedSelection = useMemo<DiffSelection | null>(() => {\n if (!diffData?.baseCommitish || !diffData?.targetCommitish) {\n return null;\n }\n\n return createDiffSelection(\n diffData.baseCommitish,\n diffData.targetCommitish,\n diffData.requestedBaseMode,\n );\n }, [diffData]);\n const resolvedSelectionKey = useMemo(() => {\n if (!resolvedSelection) {\n return null;\n }\n\n return getDiffSelectionKey(resolvedSelection);\n }, [resolvedSelection]);\n\n const { settings, updateSettings, scrollBehavior } = useAppearanceSettings();\n const { isMobile, isDesktop } = useViewport();\n\n // New diff-aware comment system\n const {\n hasLoadedComments,\n threads,\n replaceThreads,\n addThread,\n replyToThread,\n removeThread,\n removeMessage,\n updateMessage,\n clearAllComments,\n generateThreadPrompt,\n generateAllCommentsPrompt,\n } = useDiffComments(\n resolvedSelection?.baseCommitish,\n resolvedSelection?.targetCommitish,\n diffData?.commit, // Using commit as currentCommitHash\n undefined, // branchToHash map - could be populated from server data\n diffData?.repositoryId, // Repository identifier for storage isolation\n resolvedSelection?.baseMode,\n );\n\n const normalizedThreads = useMemo<CommentThread[]>(\n () =>\n threads.map((thread) => ({\n id: thread.id,\n file: thread.filePath,\n line:\n typeof thread.position.line === 'number'\n ? thread.position.line\n : ([thread.position.line.start, thread.position.line.end] as [number, number]),\n side: thread.position.side,\n createdAt: thread.createdAt,\n updatedAt: thread.updatedAt,\n codeContent: thread.codeSnapshot?.content,\n messages: thread.messages,\n })),\n [threads],\n );\n const showAuthorBadges = useMemo(\n () => hasMultipleCommentAuthors(normalizedThreads.flatMap((thread) => thread.messages)),\n [normalizedThreads],\n );\n\n const threadsByFile = useMemo(() => {\n const map = new Map<string, CommentThread[]>();\n normalizedThreads.forEach((thread) => {\n const entry = map.get(thread.file);\n if (entry) {\n entry.push(thread);\n } else {\n map.set(thread.file, [thread]);\n }\n });\n return map;\n }, [normalizedThreads]);\n const showMobileCommentsBar = isMobile && threads.length > 0;\n const commentsContextKey = useMemo(() => {\n if (!resolvedSelectionKey) {\n return null;\n }\n\n return `${diffData?.repositoryId ?? 'default'}:${resolvedSelectionKey}`;\n }, [diffData?.repositoryId, resolvedSelectionKey]);\n const commentSessionQueryString = useMemo(() => {\n if (!resolvedSelection) {\n return null;\n }\n\n const params = new URLSearchParams({\n base: resolvedSelection.baseCommitish,\n target: resolvedSelection.targetCommitish,\n });\n if (resolvedSelection.baseMode === 'merge-base') {\n params.set('baseMode', resolvedSelection.baseMode);\n }\n\n return params.toString();\n }, [resolvedSelection]);\n const getCommentApiUrl = useCallback(\n (path: string) => {\n if (!commentSessionQueryString) {\n return path;\n }\n return `${path}?${commentSessionQueryString}`;\n },\n [commentSessionQueryString],\n );\n const [bootstrappedCommentsKey, setBootstrappedCommentsKey] = useState<string | null>(null);\n const hasBootstrappedComments =\n commentsContextKey !== null && commentsContextKey === bootstrappedCommentsKey;\n const bootstrappingCommentsKeyRef = useRef<string | null>(null);\n const skipNextCommentSyncRef = useRef(false);\n const pendingBootstrapAfterLocalResetRef = useRef(false);\n\n useEffect(() => {\n if (commentsContextKey !== bootstrappedCommentsKey) {\n skipNextCommentSyncRef.current = false;\n }\n }, [bootstrappedCommentsKey, commentsContextKey]);\n\n const fetchServerThreads = useCallback(async (): Promise<DiffCommentThread[]> => {\n const response = await fetch(getCommentApiUrl('/api/comments-json'));\n if (!response.ok) {\n throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`);\n }\n\n const payload = (await response.json()) as { threads?: DiffCommentThread[] };\n return Array.isArray(payload.threads) ? payload.threads : [];\n }, [getCommentApiUrl]);\n\n const syncThreadsToServer = useCallback(\n async (nextThreads: DiffCommentThread[]) => {\n await fetch(getCommentApiUrl('/api/comments'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ threads: nextThreads }),\n });\n },\n [getCommentApiUrl],\n );\n\n // Viewed files management\n const { viewedFiles, hasLoadedInitialViewedFiles, toggleFileViewed, clearViewedFiles } =\n useViewedFiles(\n resolvedSelection?.baseCommitish,\n resolvedSelection?.targetCommitish,\n diffData?.commit,\n undefined,\n diffData?.files,\n diffData?.repositoryId, // Repository identifier for storage isolation\n settings.autoViewedPatterns,\n resolvedSelection?.baseMode,\n );\n\n // Reset initialization flag when diff context changes\n useEffect(() => {\n collapsedInitializedRef.current = false;\n }, [diffData?.repositoryId, resolvedSelectionKey, diffData?.commit]);\n\n // Initialize collapsed files from viewed files (only once per diff)\n useEffect(() => {\n if (!collapsedInitializedRef.current && hasLoadedInitialViewedFiles) {\n setCollapsedFiles(new Set(viewedFiles));\n collapsedInitializedRef.current = true;\n }\n }, [viewedFiles, hasLoadedInitialViewedFiles]);\n const {\n renderedFilePaths,\n ensureFileRendered,\n ensureFilesRenderedUpTo,\n registerLazyFileContainer,\n scrollFileIntoDiffContainer,\n } = useLazyDiffRendering({\n diffData,\n diffScrollContainerRef,\n setDiffData,\n scrollBehavior,\n });\n\n const toggleFileReviewed = useCallback(\n async (filePath: string) => {\n if (!diffData) return;\n\n const file = diffData.files.find((f) => f.path === filePath);\n if (!file) return;\n\n const wasViewed = viewedFiles.has(filePath);\n await toggleFileViewed(filePath, file);\n\n // Update collapsed state based on viewed state\n setCollapsedFiles((prev) => {\n const newSet = new Set(prev);\n if (!wasViewed) {\n // Marking as viewed -> collapse the file\n newSet.add(filePath);\n } else {\n // Marking as not viewed -> expand the file\n newSet.delete(filePath);\n }\n return newSet;\n });\n\n // When marking as reviewed (closing file), scroll to the file header\n if (!wasViewed) {\n setTimeout(() => {\n scrollFileIntoDiffContainer(filePath);\n }, 100);\n }\n },\n [diffData, scrollFileIntoDiffContainer, toggleFileViewed, viewedFiles],\n );\n\n const toggleFileCollapsed = useCallback((filePath: string) => {\n setCollapsedFiles((prev) => {\n const newSet = new Set(prev);\n if (newSet.has(filePath)) {\n newSet.delete(filePath);\n } else {\n newSet.add(filePath);\n }\n return newSet;\n });\n }, []);\n\n const toggleAllFilesCollapsed = useCallback(\n (shouldCollapse: boolean) => {\n if (!diffData) return;\n\n if (shouldCollapse) {\n // Collapse all files\n setCollapsedFiles(new Set(diffData.files.map((f) => f.path)));\n } else {\n // Expand all files\n setCollapsedFiles(new Set());\n }\n },\n [diffData],\n );\n\n const handleMobileFileSelected = useCallback(() => {\n setIsFileTreeOpen(false);\n }, []);\n\n const handleDiffModeChange = useCallback((mode: DiffViewMode) => {\n hasUserSetDiffModeRef.current = true;\n setDiffMode(mode);\n }, []);\n\n // Lift expand state to App level so navigation and rendering share the same merged chunks\n const {\n isLoading: isExpandLoading,\n expandLines,\n expandAllBetweenChunks,\n prefetchFileContent,\n getMergedChunks,\n lastUpdatedAt,\n } = useExpandedLines({\n baseCommitish: diffData?.baseCommitish,\n targetCommitish: diffData?.targetCommitish,\n diffIdentity: diffDataVersion,\n });\n\n const getMergedChunksRef = useRef(getMergedChunks);\n useEffect(() => {\n getMergedChunksRef.current = getMergedChunks;\n }, [getMergedChunks]);\n\n const [mergedChunksState, setMergedChunksState] = useState(EMPTY_MERGED_CHUNKS_STATE);\n const filesByPath = useMemo(() => {\n const map = new Map<string, DiffResponse['files'][number]>();\n diffData?.files.forEach((file) => {\n map.set(file.path, file);\n });\n return map;\n }, [diffData]);\n\n // Recompute merged chunks for the current fetched diff only.\n useEffect(() => {\n if (!diffData) {\n setMergedChunksState(EMPTY_MERGED_CHUNKS_STATE);\n return;\n }\n\n setMergedChunksState(\n buildMergedChunksState(diffDataVersion, renderedFilePaths, filesByPath, (file) =>\n getMergedChunksRef.current(file),\n ),\n );\n }, [diffData, diffDataVersion, filesByPath, renderedFilePaths, lastUpdatedAt]);\n\n // Create files with merged chunks for keyboard navigation\n const navigableFiles = useMemo(() => {\n if (!diffData) return [];\n return diffData.files.map((file) => ({\n ...file,\n chunks:\n getMergedChunksForVersion(mergedChunksState, diffDataVersion, file.path) || file.chunks,\n }));\n }, [diffData, diffDataVersion, mergedChunksState]);\n\n // State to trigger comment creation from keyboard\n const [commentTrigger, setCommentTrigger] = useState<{\n fileIndex: number;\n chunkIndex: number;\n lineIndex: number;\n } | null>(null);\n const fetchDiffDataRef = useRef<((selection?: DiffSelection) => Promise<void>) | null>(null);\n const handleWatchReload = useCallback(async () => {\n await fetchDiffDataRef.current?.();\n }, []);\n const handleCommentsChanged = useCallback(async () => {\n try {\n const serverThreads = await fetchServerThreads();\n skipNextCommentSyncRef.current = true;\n replaceThreads(serverThreads);\n if (commentsContextKey) {\n setBootstrappedCommentsKey(commentsContextKey);\n }\n } catch (commentsError) {\n console.error('Failed to refresh comments from server:', commentsError);\n }\n }, [commentsContextKey, fetchServerThreads, replaceThreads]);\n\n // File watch for reload functionality - initialize with callback\n const { shouldReload, reload, watchState } = useFileWatch(\n handleWatchReload,\n handleCommentsChanged,\n );\n\n const { cursor, isHelpOpen, setIsHelpOpen, setCursorPosition } = useKeyboardNavigation({\n files: navigableFiles,\n comments: normalizedThreads,\n viewMode: diffMode,\n reviewedFiles: viewedFiles,\n onToggleReviewed: toggleFileReviewed,\n onCreateComment: () => {\n if (cursor) {\n setCommentTrigger({\n fileIndex: cursor.fileIndex,\n chunkIndex: cursor.chunkIndex,\n lineIndex: cursor.lineIndex,\n });\n }\n },\n onCopyAllComments: () => {\n if (threads.length > 0) {\n void handleCopyAllComments();\n }\n },\n onDeleteAllComments: () => {\n if (threads.length > 0 && confirm('Delete all comments?')) {\n clearAllComments();\n }\n },\n onShowCommentsList: () => {\n setIsCommentsListOpen(true);\n },\n onRefresh: () => {\n reload();\n },\n });\n\n useEffect(() => {\n if (!diffData || !cursor) return;\n\n const filePath = diffData.files[cursor.fileIndex]?.path;\n if (!filePath || renderedFilePaths.has(filePath)) return;\n\n ensureFilesRenderedUpTo(filePath);\n requestAnimationFrame(() => {\n setCursorPosition(cursor);\n });\n }, [cursor, diffData, ensureFilesRenderedUpTo, renderedFilePaths, setCursorPosition]);\n\n const handleLineClick = useCallback(\n (fileIndex: number, chunkIndex: number, lineIndex: number, side: 'left' | 'right') => {\n setCursorPosition({\n fileIndex,\n chunkIndex,\n lineIndex,\n side,\n });\n },\n [setCursorPosition],\n );\n\n const handleCommentTriggerHandled = useCallback(() => {\n setCommentTrigger(null);\n }, [setCommentTrigger]);\n\n const handleGenerateThreadPrompt = useCallback(\n (thread: CommentThread) => generateThreadPrompt(thread.id),\n [generateThreadPrompt],\n );\n\n const handleMouseDown = (e: React.MouseEvent) => {\n e.preventDefault();\n setIsDragging(true);\n const startX = e.clientX;\n const startWidth = sidebarWidth;\n\n const handleMouseMove = (e: MouseEvent) => {\n const newWidth = Math.max(\n SIDEBAR_MIN_WIDTH,\n Math.min(SIDEBAR_MAX_WIDTH, startWidth + (e.clientX - startX)),\n );\n setSidebarWidth(newWidth);\n };\n\n const handleMouseUp = () => {\n setIsDragging(false);\n document.removeEventListener('mousemove', handleMouseMove);\n document.removeEventListener('mouseup', handleMouseUp);\n };\n\n document.addEventListener('mousemove', handleMouseMove);\n document.addEventListener('mouseup', handleMouseUp);\n };\n\n const fetchDiffData = useCallback(\n async (selection?: DiffSelection) => {\n const requestId = diffRequestIdRef.current + 1;\n diffRequestIdRef.current = requestId;\n activeDiffAbortControllerRef.current?.abort();\n const controller = new AbortController();\n activeDiffAbortControllerRef.current = controller;\n try {\n const requestedSelection =\n selection ??\n (hasUserSelectedRevisionRef.current ? selectedRevisionRef.current : undefined);\n const params = new URLSearchParams({\n ignoreWhitespace: String(ignoreWhitespace),\n });\n if (requestedSelection?.baseCommitish) params.set('base', requestedSelection.baseCommitish);\n if (requestedSelection?.targetCommitish)\n params.set('target', requestedSelection.targetCommitish);\n if (requestedSelection?.baseMode === 'merge-base')\n params.set('baseMode', requestedSelection.baseMode);\n\n const response = await fetch(`/api/diff?${params}`, {\n signal: controller.signal,\n });\n if (!response.ok) throw new Error('Failed to fetch diff data');\n const data = (await response.json()) as DiffResponse;\n if (diffRequestIdRef.current !== requestId) {\n return;\n }\n setDiffData(data);\n setDiffDataVersion((prev) => prev + 1);\n\n // Update resolved revision state from server response\n setResolvedBaseRevision(\n data.baseCommitish && data.requestedBaseMode !== 'merge-base' ? data.baseCommitish : '',\n );\n if (data.targetCommitish) setResolvedTargetRevision(data.targetCommitish);\n\n if (!hasUserSelectedRevisionRef.current) {\n const requestedBase = data.requestedBaseCommitish ?? data.baseCommitish;\n const requestedTarget = data.requestedTargetCommitish ?? data.targetCommitish;\n if (requestedBase && requestedTarget) {\n setSelectedRevision(\n createDiffSelection(requestedBase, requestedTarget, data.requestedBaseMode),\n );\n }\n }\n\n // Set diff mode from server response if provided\n if (data.mode && !hasUserSetDiffModeRef.current) {\n setDiffMode(normalizeDiffViewMode(data.mode));\n }\n\n // Lock files are now automatically marked as viewed by useViewedFiles hook\n } catch (err) {\n if ((err as { name?: string } | null)?.name === 'AbortError') {\n return;\n }\n if (diffRequestIdRef.current !== requestId) {\n return;\n }\n setError(err instanceof Error ? err.message : 'Unknown error');\n } finally {\n if (activeDiffAbortControllerRef.current === controller) {\n activeDiffAbortControllerRef.current = null;\n }\n if (diffRequestIdRef.current === requestId) {\n setLoading(false);\n }\n }\n },\n [ignoreWhitespace],\n );\n fetchDiffDataRef.current = fetchDiffData;\n\n useEffect(() => {\n void fetchDiffData();\n }, [fetchDiffData]);\n\n useEffect(() => {\n return () => {\n activeDiffAbortControllerRef.current?.abort();\n };\n }, []);\n\n useEffect(() => {\n if (isMobile && diffMode !== 'unified') {\n setDiffMode('unified');\n }\n }, [diffMode, isMobile]);\n\n useEffect(() => {\n try {\n window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth));\n } catch {\n // Ignore localStorage errors (e.g. disabled storage).\n }\n }, [sidebarWidth]);\n\n useEffect(() => {\n try {\n window.localStorage.setItem(SIDEBAR_OPEN_STORAGE_KEY, String(isFileTreeOpen));\n } catch {\n // Ignore localStorage errors (e.g. disabled storage).\n }\n }, [isFileTreeOpen]);\n\n // Fetch revision options on mount\n useEffect(() => {\n fetch('/api/revisions')\n .then((res) => (res.ok ? res.json() : null))\n .then((data: RevisionsResponse | null) => {\n setRevisionOptions(data);\n if (\n data?.resolvedBase &&\n normalizeBaseMode(currentRequestedBaseModeRef.current) !== 'merge-base'\n ) {\n setResolvedBaseRevision((prev) => prev || data.resolvedBase || '');\n }\n if (data?.resolvedTarget) {\n setResolvedTargetRevision((prev) => prev || data.resolvedTarget || '');\n }\n })\n .catch(() => setRevisionOptions(null));\n }, []);\n\n // Handle revision change\n const handleRevisionChange = useCallback(\n async (nextSelection: DiffSelection) => {\n // Skip if no actual change\n if (diffSelectionsEqual(nextSelection, selectedRevision)) return;\n\n hasUserSelectedRevisionRef.current = true;\n selectedRevisionRef.current = nextSelection;\n setSelectedRevision(nextSelection);\n setLoading(true);\n setError(null);\n await fetchDiffData(nextSelection);\n },\n [fetchDiffData, selectedRevision],\n );\n\n // Clear comments and viewed files on initial load if requested via CLI flag\n const hasCleanedRef = useRef(false);\n useEffect(() => {\n if (diffData?.clearComments && !hasCleanedRef.current) {\n hasCleanedRef.current = true;\n pendingBootstrapAfterLocalResetRef.current = true;\n clearAllComments({ resetAppliedCommentImportIds: true });\n clearViewedFiles();\n console.log(\n '✅ All existing comments and viewed files cleared as requested via --clean flag',\n );\n }\n }, [diffData?.clearComments, clearAllComments, clearViewedFiles]);\n\n useEffect(() => {\n if (!commentsContextKey || !hasLoadedComments) {\n return;\n }\n\n if (bootstrappedCommentsKey === commentsContextKey) {\n return;\n }\n\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n return;\n }\n\n if (pendingBootstrapAfterLocalResetRef.current) {\n pendingBootstrapAfterLocalResetRef.current = false;\n return;\n }\n\n bootstrappingCommentsKeyRef.current = commentsContextKey;\n let cancelled = false;\n\n const bootstrapComments = async () => {\n try {\n const serverThreads = await fetchServerThreads();\n const mergedThreads = mergeCommentThreads(serverThreads, threads).threads;\n if (cancelled) {\n return;\n }\n\n skipNextCommentSyncRef.current = true;\n replaceThreads(mergedThreads);\n\n if (JSON.stringify(serverThreads) !== JSON.stringify(mergedThreads)) {\n await syncThreadsToServer(mergedThreads);\n }\n } catch (commentsError) {\n if (!cancelled) {\n console.error('Failed to bootstrap comments from server:', commentsError);\n }\n } finally {\n if (!cancelled) {\n setBootstrappedCommentsKey(commentsContextKey);\n }\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n bootstrappingCommentsKeyRef.current = null;\n }\n }\n };\n\n void bootstrapComments();\n\n return () => {\n cancelled = true;\n if (bootstrappingCommentsKeyRef.current === commentsContextKey) {\n bootstrappingCommentsKeyRef.current = null;\n }\n };\n }, [\n bootstrappedCommentsKey,\n commentsContextKey,\n fetchServerThreads,\n hasLoadedComments,\n replaceThreads,\n syncThreadsToServer,\n threads,\n ]);\n\n // Trigger sparkle animation when all files are viewed\n useEffect(() => {\n if (diffData) {\n // Reset the trigger flag when not all files are viewed\n if (viewedFiles.size < diffData.files.length) {\n setHasTriggeredSparkles(false);\n }\n // Show sparkles when all files are viewed and not already triggered\n else if (viewedFiles.size === diffData.files.length && !hasTriggeredSparkles) {\n setShowSparkles(true);\n setHasTriggeredSparkles(true);\n // Hide sparkles after animation completes\n setTimeout(() => {\n setShowSparkles(false);\n }, 1000);\n }\n }\n }, [viewedFiles.size, diffData, hasTriggeredSparkles]);\n\n // Send comments to server whenever they change and before page unload\n useEffect(() => {\n if (!hasBootstrappedComments) {\n return;\n }\n\n const data = JSON.stringify({ threads });\n const commentsApiUrl = getCommentApiUrl('/api/comments');\n\n // Also handle page unload\n const sendCommentsBeforeUnload = () => {\n // Use sendBeacon for reliable delivery during page unload, including empty states.\n navigator.sendBeacon(commentsApiUrl, data);\n };\n\n window.addEventListener('beforeunload', sendCommentsBeforeUnload);\n\n if (skipNextCommentSyncRef.current) {\n skipNextCommentSyncRef.current = false;\n return () => {\n window.removeEventListener('beforeunload', sendCommentsBeforeUnload);\n };\n }\n\n syncThreadsToServer(threads).catch((syncError) => {\n console.error('Failed to sync comments:', syncError);\n });\n\n return () => {\n window.removeEventListener('beforeunload', sendCommentsBeforeUnload);\n };\n }, [getCommentApiUrl, hasBootstrappedComments, syncThreadsToServer, threads]);\n\n // Establish SSE connection for tab close detection\n useEffect(() => {\n const eventSource = new EventSource(resolveEventSourceUrl('/api/heartbeat'));\n\n eventSource.onopen = () => {\n console.log('Connected to server heartbeat');\n };\n\n eventSource.onerror = () => {\n console.log('Server connection lost');\n eventSource.close();\n };\n\n // Cleanup on unmount\n return () => {\n eventSource.close();\n };\n }, []);\n\n const handleAddComment = useCallback(\n (\n file: string,\n line: LineNumber,\n body: string,\n codeContent?: string,\n side?: DiffSide,\n ): Promise<void> => {\n addThread({\n filePath: file,\n body,\n side: side || 'new',\n line: typeof line === 'number' ? line : { start: line[0], end: line[1] },\n codeSnapshot: codeContent\n ? {\n content: codeContent,\n language: undefined,\n }\n : undefined,\n });\n return Promise.resolve();\n },\n [addThread],\n );\n\n const handleCopyAllComments = async () => {\n try {\n const prompt = generateAllCommentsPrompt();\n await copyTextToClipboard(prompt);\n setIsCopiedAll(true);\n setTimeout(() => setIsCopiedAll(false), 2000);\n } catch (error) {\n console.error('Failed to copy all comments prompt:', error);\n }\n };\n\n const handleReplyToThread = useCallback(\n (threadId: string, body: string): Promise<void> => {\n replyToThread({ threadId, body });\n return Promise.resolve();\n },\n [replyToThread],\n );\n\n const handleNavigateToComment = (thread: CommentThread) => {\n if (!diffData) return;\n\n const position = findCommentPosition(thread, diffData.files);\n if (position) {\n setCursorPosition(position);\n }\n };\n\n const handleOpenInEditor = useCallback(\n async (filePath: string, lineNumber: number) => {\n try {\n const response = await fetch('/api/open-in-editor', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filePath, line: lineNumber, editor: settings.editor }),\n });\n\n if (!response.ok) {\n const payload: unknown = await response.json().catch(() => null);\n let message = response.statusText;\n if (\n payload &&\n typeof payload === 'object' &&\n 'error' in payload &&\n typeof (payload as { error?: unknown }).error === 'string'\n ) {\n message = (payload as { error: string }).error;\n }\n console.error('Failed to open file in editor:', message);\n }\n } catch (error) {\n console.error('Failed to open file in editor:', error);\n }\n },\n [settings.editor],\n );\n\n const handleGlobalClick = (e: React.MouseEvent) => {\n // Clear cursor position\n setCursorPosition(null);\n\n // Check if clicking on a comment button\n const target = e.target as HTMLElement;\n const isCommentButton = target.closest('[data-comment-button=\"true\"]');\n const isOpenInEditorButton = target.closest('[data-open-in-editor-button=\"true\"]');\n\n // Close empty comment forms (unless clicking on a comment button)\n if (!isCommentButton && !isOpenInEditorButton) {\n closeEmptyCommentForms(e);\n }\n };\n\n const closeEmptyCommentForms = (e: React.MouseEvent) => {\n const emptyForms = document.querySelectorAll('form[data-empty=\"true\"]');\n emptyForms.forEach((form) => {\n // Don't close if clicking inside the form itself\n if (!form.contains(e.target as Node)) {\n const cancelButton = form.querySelector('button[type=\"button\"]') as HTMLButtonElement;\n cancelButton?.click();\n }\n });\n };\n\n if (loading) {\n return (\n <div className=\"flex items-center justify-center h-screen bg-github-bg-primary\">\n <div className=\"text-github-text-secondary text-base\">Loading diff...</div>\n </div>\n );\n }\n\n if (error) {\n return (\n <div className=\"flex flex-col items-center justify-center h-screen bg-github-bg-primary text-center gap-2\">\n <h2 className=\"text-github-danger text-2xl mb-2\">Error</h2>\n <p className=\"text-github-text-secondary text-base\">{error}</p>\n </div>\n );\n }\n\n if (!diffData) {\n return (\n <div className=\"flex flex-col items-center justify-center h-screen bg-github-bg-primary text-center gap-2\">\n <h2 className=\"text-github-danger text-2xl mb-2\">No data</h2>\n <p className=\"text-github-text-secondary text-base\">No diff data available</p>\n </div>\n );\n }\n\n const canOpenInEditor = settings.editor !== 'none' && diffData.openInEditorAvailable !== false;\n\n return (\n <WordHighlightProvider>\n <div className=\"h-screen flex flex-col\" onClickCapture={handleGlobalClick}>\n <header\n className={`bg-github-bg-secondary border-b border-github-border flex ${\n isMobile ? 'flex-col' : 'flex-row items-center'\n }`}\n >\n <div\n className={`flex items-center justify-between w-full ${\n isMobile ? 'px-3 py-2 gap-3' : 'px-4 py-3 gap-4 w-auto'\n } ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isMobile ? '100%' : isFileTreeOpen ? `${sidebarWidth}px` : 'auto',\n minWidth: isMobile ? '0px' : isFileTreeOpen ? '200px' : 'auto',\n maxWidth: isMobile ? 'none' : isFileTreeOpen ? '600px' : 'none',\n }}\n >\n <h1>\n <Logo style={{ height: '18px', color: 'var(--color-github-text-secondary)' }} />\n </h1>\n <div className=\"flex items-center gap-1\">\n <button\n onClick={() => setIsFileTreeOpen(!isFileTreeOpen)}\n className=\"p-2 text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary rounded transition-colors\"\n title={isFileTreeOpen ? 'Collapse file tree' : 'Expand file tree'}\n aria-expanded={isFileTreeOpen}\n aria-controls=\"file-tree-panel\"\n aria-label=\"Toggle file tree panel\"\n >\n {isFileTreeOpen ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}\n </button>\n <button\n onClick={() => setIsSettingsOpen(true)}\n className=\"p-2 text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary rounded transition-colors\"\n title=\"Settings\"\n >\n <Settings size={18} />\n </button>\n </div>\n </div>\n {!isMobile && (\n <div\n className={`border-r border-github-border ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isFileTreeOpen ? '4px' : '0px',\n height: 'calc(100% - 16px)',\n margin: '8px 0',\n transform: 'translateX(-2px)',\n }}\n />\n )}\n <div\n className={`flex-1 flex flex-wrap items-center justify-between ${\n isMobile ? 'px-3 pb-2 gap-3' : 'px-4 py-3 gap-4'\n }`}\n >\n <div className={`flex flex-wrap items-center ${isMobile ? 'gap-2' : 'gap-3'}`}>\n {!isMobile && (\n <div className=\"flex bg-github-bg-tertiary border border-github-border rounded-md p-1\">\n <button\n onClick={() => handleDiffModeChange('split')}\n className={`px-3 py-1.5 text-xs font-medium rounded transition-all duration-200 flex items-center gap-1.5 cursor-pointer ${\n diffMode === 'split'\n ? 'bg-github-bg-primary text-github-text-primary shadow-sm'\n : 'text-github-text-secondary hover:text-github-text-primary'\n }`}\n >\n <Columns size={14} />\n Split\n </button>\n <button\n onClick={() => handleDiffModeChange('unified')}\n className={`px-3 py-1.5 text-xs font-medium rounded transition-all duration-200 flex items-center gap-1.5 cursor-pointer ${\n diffMode === 'unified'\n ? 'bg-github-bg-primary text-github-text-primary shadow-sm'\n : 'text-github-text-secondary hover:text-github-text-primary'\n }`}\n >\n <AlignLeft size={14} />\n Unified\n </button>\n </div>\n )}\n <Checkbox\n checked={ignoreWhitespace}\n onChange={setIgnoreWhitespace}\n label=\"Ignore Whitespace\"\n title={ignoreWhitespace ? 'Show whitespace changes' : 'Ignore whitespace changes'}\n />\n {/* File Watch Reload Button */}\n <ReloadButton\n shouldReload={shouldReload}\n isReloading={watchState.isReloading}\n onReload={reload}\n changeType={watchState.lastChangeType}\n compact={isMobile}\n />\n </div>\n <div\n className={`flex flex-wrap items-center text-sm text-github-text-secondary ${\n isMobile ? 'gap-3' : 'gap-4'\n }`}\n >\n {!isMobile && threads.length > 0 && (\n <CommentsDropdown\n commentsCount={threads.length}\n isCopiedAll={isCopiedAll}\n onCopyAll={handleCopyAllComments}\n onDeleteAll={clearAllComments}\n onViewAll={() => setIsCommentsListOpen(true)}\n />\n )}\n <div className=\"flex flex-col gap-1 items-center\">\n <div className=\"text-xs relative\">\n {viewedFiles.size === diffData.files.length\n ? 'All diffs difit-ed!'\n : `${viewedFiles.size} / ${diffData.files.length} files viewed`}\n <SparkleAnimation isActive={showSparkles} />\n </div>\n <div\n className=\"relative h-2 bg-github-bg-tertiary rounded-full overflow-hidden\"\n style={{\n width: '90px',\n border: '1px solid var(--color-github-border)',\n }}\n >\n <div\n className=\"absolute top-0 right-0 h-full transition-all duration-300 ease-out\"\n style={{\n width: `${((diffData.files.length - viewedFiles.size) / diffData.files.length) * 100}%`,\n backgroundColor: (() => {\n const remainingPercent =\n ((diffData.files.length - viewedFiles.size) / diffData.files.length) *\n 100;\n if (remainingPercent > 50) return 'var(--color-github-accent)'; // green\n if (remainingPercent > 20) return 'var(--color-github-warning)'; // yellow\n return 'var(--color-github-danger)'; // red\n })(),\n }}\n />\n </div>\n </div>\n {revisionOptions ? (\n <DiffQuickMenu\n options={revisionOptions}\n selection={selectedRevision}\n resolvedBaseRevision={resolvedBaseRevision}\n resolvedTargetRevision={resolvedTargetRevision}\n onSelectDiff={(selection) => void handleRevisionChange(selection)}\n onOpenAdvanced={() => setIsRevisionModalOpen(true)}\n compact={!isDesktop}\n />\n ) : (\n <span className=\"text-xs\">\n Reviewing:{' '}\n <code className=\"bg-github-bg-tertiary px-1.5 py-0.5 rounded text-xs text-github-text-primary\">\n {diffData.commit.includes('...') ? (\n <>\n <span className=\"text-github-text-secondary font-medium\">\n {diffData.commit.split('...')[0]}...\n </span>\n <span className=\"font-medium\">{diffData.commit.split('...')[1]}</span>\n </>\n ) : (\n diffData.commit\n )}\n </code>\n </span>\n )}\n </div>\n </div>\n </header>\n {revisionOptions && (\n <RevisionDetailModal\n key={isRevisionModalOpen ? getDiffSelectionKey(selectedRevision) : 'closed'}\n isOpen={isRevisionModalOpen}\n onClose={() => setIsRevisionModalOpen(false)}\n options={revisionOptions}\n selection={selectedRevision}\n resolvedBaseRevision={resolvedBaseRevision}\n resolvedTargetRevision={resolvedTargetRevision}\n onApply={(selection) => void handleRevisionChange(selection)}\n />\n )}\n\n {isMobile && isFileTreeOpen && (\n <button\n type=\"button\"\n aria-label=\"Close file tree\"\n className=\"fixed inset-0 bg-black/40 z-30\"\n onClick={() => setIsFileTreeOpen(false)}\n />\n )}\n\n <div className=\"flex flex-1 overflow-hidden relative\">\n <div\n className={`relative overflow-hidden ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isMobile ? '0px' : isFileTreeOpen ? `${sidebarWidth}px` : '0px',\n }}\n >\n <aside\n id=\"file-tree-panel\"\n className={`bg-github-bg-secondary overflow-y-auto flex flex-col ${\n isMobile\n ? 'fixed inset-y-0 right-0 z-40 w-[min(85vw,360px)] border-l border-github-border transition-transform duration-300 ease-out'\n : 'relative border-r border-github-border'\n }`}\n style={{\n width: isMobile ? 'min(85vw, 360px)' : `${sidebarWidth}px`,\n minWidth: isMobile ? '0px' : '200px',\n maxWidth: isMobile ? 'none' : '600px',\n height: '100%',\n transform: isMobile\n ? isFileTreeOpen\n ? 'translateX(0)'\n : 'translateX(100%)'\n : undefined,\n }}\n >\n <div className=\"flex-1 overflow-y-auto\">\n <FileList\n files={diffData.files}\n onScrollToFile={scrollFileIntoDiffContainer}\n onFileSelected={isMobile ? handleMobileFileSelected : undefined}\n comments={normalizedThreads}\n reviewedFiles={viewedFiles}\n onToggleReviewed={toggleFileReviewed}\n selectedFileIndex={cursor?.fileIndex ?? null}\n />\n </div>\n {!isMobile && (\n <div className=\"p-4 border-t border-github-border flex justify-between items-center\">\n <button\n onClick={() => setIsHelpOpen(true)}\n className=\"flex items-center gap-1.5 text-github-text-secondary hover:text-github-text-primary transition-colors\"\n title=\"Keyboard shortcuts (Shift+?)\"\n >\n <Keyboard size={16} />\n <span className=\"text-sm\">Shortcuts</span>\n </button>\n <a\n href=\"https://github.com/yoshiko-pg/difit\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-2 text-github-text-secondary hover:text-github-text-primary transition-colors\"\n title=\"View on GitHub\"\n >\n <span className=\"text-sm\">Star on GitHub</span>\n <GitHubIcon style={{ height: '18px', width: '18px' }} />\n </a>\n </div>\n )}\n </aside>\n </div>\n\n {!isMobile && (\n <div\n className={`bg-github-border hover:bg-github-text-muted cursor-col-resize ${!isDragging ? '!transition-all !duration-300 !ease-in-out' : ''}`}\n style={{\n width: isFileTreeOpen ? '4px' : '0px',\n }}\n onMouseDown={handleMouseDown}\n title=\"Drag to resize file list\"\n />\n )}\n\n <main\n ref={diffScrollContainerRef}\n className={`flex-1 overflow-y-auto ${showMobileCommentsBar ? 'pb-16' : ''}`}\n >\n {diffData.files.map((file, fileIndex) => {\n const fileThreads = threadsByFile.get(file.path) ?? EMPTY_COMMENT_THREADS;\n const mergedChunks =\n getMergedChunksForVersion(mergedChunksState, diffDataVersion, file.path) ??\n EMPTY_MERGED_CHUNKS;\n const isRendered = renderedFilePaths.has(file.path);\n return (\n <div\n key={file.path}\n id={getFileElementId(file.path)}\n data-file-path={file.path}\n data-rendered={isRendered ? 'true' : 'false'}\n ref={(node) => registerLazyFileContainer(file.path, node)}\n className=\"mb-6\"\n >\n {isRendered ? (\n <DiffViewer\n file={file}\n threads={fileThreads}\n showAuthorBadges={showAuthorBadges}\n diffMode={diffMode}\n reviewedFiles={viewedFiles}\n onToggleReviewed={toggleFileReviewed}\n collapsedFiles={collapsedFiles}\n onToggleCollapsed={toggleFileCollapsed}\n onToggleAllCollapsed={toggleAllFilesCollapsed}\n onAddComment={handleAddComment}\n onGenerateThreadPrompt={handleGenerateThreadPrompt}\n onRemoveThread={removeThread}\n onReplyToThread={handleReplyToThread}\n onRemoveMessage={removeMessage}\n onUpdateMessage={updateMessage}\n onOpenInEditor={canOpenInEditor ? handleOpenInEditor : undefined}\n syntaxTheme={settings.syntaxTheme}\n baseCommitish={diffData.baseCommitish}\n targetCommitish={diffData.targetCommitish}\n cursor={cursor?.fileIndex === fileIndex ? cursor : null}\n fileIndex={fileIndex}\n onLineClick={handleLineClick}\n commentTrigger={\n commentTrigger?.fileIndex === fileIndex ? commentTrigger : null\n }\n onCommentTriggerHandled={handleCommentTriggerHandled}\n mergedChunks={mergedChunks}\n expandLines={expandLines}\n expandAllBetweenChunks={expandAllBetweenChunks}\n prefetchFileContent={prefetchFileContent}\n isExpandLoading={isExpandLoading}\n />\n ) : (\n <div className=\"bg-github-bg-secondary border border-github-border rounded-md px-4 py-3\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"min-w-0\">\n <div className=\"text-xs uppercase tracking-wide text-github-text-muted\">\n Deferred Rendering\n </div>\n <div className=\"text-sm font-mono text-github-text-primary truncate\">\n {file.path}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => ensureFileRendered(file.path)}\n className=\"px-3 py-1.5 text-xs rounded border border-github-border text-github-text-secondary hover:text-github-text-primary hover:bg-github-bg-tertiary\"\n >\n Load now\n </button>\n </div>\n </div>\n )}\n </div>\n );\n })}\n </main>\n </div>\n\n {showMobileCommentsBar && (\n <div className=\"fixed bottom-0 left-0 right-0 z-20 bg-github-bg-secondary border-t border-github-border px-4 py-2 flex justify-end\">\n <CommentsDropdown\n commentsCount={threads.length}\n isCopiedAll={isCopiedAll}\n onCopyAll={handleCopyAllComments}\n onDeleteAll={clearAllComments}\n onViewAll={() => setIsCommentsListOpen(true)}\n direction=\"up\"\n compact\n />\n </div>\n )}\n\n {isSettingsOpen && (\n <SettingsModal\n isOpen={isSettingsOpen}\n onClose={() => setIsSettingsOpen(false)}\n settings={settings}\n onSettingsChange={updateSettings}\n />\n )}\n\n <HelpModal isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />\n\n <CommentsListModal\n isOpen={isCommentsListOpen}\n onClose={() => setIsCommentsListOpen(false)}\n onNavigate={handleNavigateToComment}\n comments={normalizedThreads}\n showAuthorBadges={showAuthorBadges}\n onRemoveThread={removeThread}\n onGenerateThreadPrompt={handleGenerateThreadPrompt}\n onReplyToThread={handleReplyToThread}\n onRemoveMessage={removeMessage}\n onUpdateMessage={updateMessage}\n syntaxTheme={settings.syntaxTheme}\n />\n </div>\n </WordHighlightProvider>\n );\n}\n\nexport default App;\n","7d40fd4:src/client/components/SettingsModal.test.tsx":"import { fireEvent, render, screen, waitFor } from '@testing-library/react';\nimport React from 'react';\nimport { HotkeysProvider } from 'react-hotkeys-hook';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { SettingsModal } from './SettingsModal';\n\nvi.mock('react-hotkeys-hook', () => ({\n useHotkeysContext: vi.fn(() => ({\n enableScope: vi.fn(),\n disableScope: vi.fn(),\n })),\n HotkeysProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nconst wrapper = ({ children }: { children: React.ReactNode }) => (\n <HotkeysProvider initiallyActiveScopes={['navigation']}>{children}</HotkeysProvider>\n);\n\nconst baseSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark' as const,\n syntaxTheme: 'vsDark',\n editor: 'cursor' as const,\n colorVision: 'normal' as const,\n autoViewedPatterns: [],\n};\n\ndescribe('SettingsModal', () => {\n it('shows appearance settings by default and moves editor selection into the system section', () => {\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={vi.fn()}\n />,\n { wrapper },\n );\n\n expect(screen.getByText('Font Size')).toBeInTheDocument();\n expect(screen.queryByText('Open In Editor')).not.toBeInTheDocument();\n expect(\n screen.queryByText('Theme, typography, and syntax highlighting.'),\n ).not.toBeInTheDocument();\n expect(screen.getAllByText('Appearance')).toHaveLength(1);\n expect(screen.getByRole('button', { name: /^Appearance/ })).toHaveAttribute(\n 'aria-pressed',\n 'true',\n );\n\n fireEvent.click(screen.getByRole('button', { name: /^System/ }));\n\n expect(screen.getByText('Open In Editor')).toBeInTheDocument();\n expect(screen.queryByText('Font Size')).not.toBeInTheDocument();\n expect(screen.getByRole('button', { name: /^System/ })).toHaveAttribute('aria-pressed', 'true');\n });\n\n it('shows the deuteranopia explanation only while the button is hovered', async () => {\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={vi.fn()}\n />,\n { wrapper },\n );\n\n expect(\n screen.queryByText('Deuteranopia mode uses blue/orange instead of green/red for diffs.'),\n ).not.toBeInTheDocument();\n\n const deuteranopiaButton = screen.getByRole('button', { name: 'Deuteranopia' });\n fireEvent.mouseEnter(deuteranopiaButton);\n\n const tooltip = await screen.findByRole('tooltip');\n expect(tooltip).toHaveTextContent(\n 'Deuteranopia mode uses blue/orange instead of green/red for diffs.',\n );\n expect(deuteranopiaButton).toHaveAttribute('aria-describedby', tooltip.id);\n\n fireEvent.mouseLeave(deuteranopiaButton);\n\n await waitFor(() => {\n expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();\n expect(deuteranopiaButton).not.toHaveAttribute('aria-describedby');\n });\n });\n\n it('edits auto-viewed patterns from the system section as newline-delimited values', () => {\n const onSettingsChange = vi.fn();\n\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={onSettingsChange}\n />,\n { wrapper },\n );\n\n fireEvent.click(screen.getByRole('button', { name: /^System/ }));\n\n const textarea = screen.getByLabelText('Auto-Mark Viewed Patterns');\n fireEvent.change(textarea, { target: { value: '*.test.ts\\nsrc/generated/**' } });\n\n expect(onSettingsChange).toHaveBeenLastCalledWith({\n ...baseSettings,\n autoViewedPatterns: ['*.test.ts', 'src/generated/**'],\n });\n });\n});\n","a72112f:src/client/components/SettingsModal.test.tsx":"import { fireEvent, render, screen, waitFor } from '@testing-library/react';\nimport React from 'react';\nimport { HotkeysProvider } from 'react-hotkeys-hook';\nimport { describe, expect, it, vi } from 'vitest';\n\nimport { SettingsModal } from './SettingsModal';\n\nvi.mock('react-hotkeys-hook', () => ({\n useHotkeysContext: vi.fn(() => ({\n enableScope: vi.fn(),\n disableScope: vi.fn(),\n })),\n HotkeysProvider: ({ children }: { children: React.ReactNode }) => children,\n}));\n\nconst wrapper = ({ children }: { children: React.ReactNode }) => (\n <HotkeysProvider initiallyActiveScopes={['navigation']}>{children}</HotkeysProvider>\n);\n\nconst baseSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark' as const,\n syntaxTheme: 'vsDark',\n editor: 'cursor' as const,\n colorVision: 'normal' as const,\n scrollAnimation: 'auto' as const,\n autoViewedPatterns: [],\n};\n\ndescribe('SettingsModal', () => {\n it('shows appearance settings by default and moves editor selection into the system section', () => {\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={vi.fn()}\n />,\n { wrapper },\n );\n\n expect(screen.getByText('Font Size')).toBeInTheDocument();\n expect(screen.queryByText('Open In Editor')).not.toBeInTheDocument();\n expect(\n screen.queryByText('Theme, typography, and syntax highlighting.'),\n ).not.toBeInTheDocument();\n expect(screen.getAllByText('Appearance')).toHaveLength(1);\n expect(screen.getByRole('button', { name: /^Appearance/ })).toHaveAttribute(\n 'aria-pressed',\n 'true',\n );\n\n fireEvent.click(screen.getByRole('button', { name: /^System/ }));\n\n expect(screen.getByText('Open In Editor')).toBeInTheDocument();\n expect(screen.queryByText('Font Size')).not.toBeInTheDocument();\n expect(screen.getByRole('button', { name: /^System/ })).toHaveAttribute('aria-pressed', 'true');\n });\n\n it('shows the deuteranopia explanation only while the button is hovered', async () => {\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={vi.fn()}\n />,\n { wrapper },\n );\n\n expect(\n screen.queryByText('Deuteranopia mode uses blue/orange instead of green/red for diffs.'),\n ).not.toBeInTheDocument();\n\n const deuteranopiaButton = screen.getByRole('button', { name: 'Deuteranopia' });\n fireEvent.mouseEnter(deuteranopiaButton);\n\n const tooltip = await screen.findByRole('tooltip');\n expect(tooltip).toHaveTextContent(\n 'Deuteranopia mode uses blue/orange instead of green/red for diffs.',\n );\n expect(deuteranopiaButton).toHaveAttribute('aria-describedby', tooltip.id);\n\n fireEvent.mouseLeave(deuteranopiaButton);\n\n await waitFor(() => {\n expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();\n expect(deuteranopiaButton).not.toHaveAttribute('aria-describedby');\n });\n });\n\n it('edits auto-viewed patterns from the system section as newline-delimited values', () => {\n const onSettingsChange = vi.fn();\n\n render(\n <SettingsModal\n isOpen={true}\n onClose={vi.fn()}\n settings={baseSettings}\n onSettingsChange={onSettingsChange}\n />,\n { wrapper },\n );\n\n fireEvent.click(screen.getByRole('button', { name: /^System/ }));\n\n const textarea = screen.getByLabelText('Auto-Mark Viewed Patterns');\n fireEvent.change(textarea, { target: { value: '*.test.ts\\nsrc/generated/**' } });\n\n expect(onSettingsChange).toHaveBeenLastCalledWith({\n ...baseSettings,\n autoViewedPatterns: ['*.test.ts', 'src/generated/**'],\n });\n });\n});\n","7d40fd4:src/client/components/SettingsModal.tsx":"import { Settings, X } from 'lucide-react';\nimport { useState, useEffect } from 'react';\nimport { useHotkeysContext } from 'react-hotkeys-hook';\n\nimport { DEFAULT_EDITOR_ID, EDITOR_OPTIONS, type EditorOptionId } from '../../utils/editorOptions';\nimport type { ColorVisionMode } from '../utils/appearanceTheme';\nimport { formatAutoViewedPatterns, parseAutoViewedPatterns } from '../utils/autoViewedPatterns';\nimport {\n getFallbackSyntaxTheme,\n getThemesForResolvedTheme,\n isSyntaxThemeForResolvedTheme,\n} from '../utils/themeLoader';\nimport { Tooltip } from './Tooltip';\n\ninterface AppearanceSettings {\n fontSize: number;\n fontFamily: string;\n theme: 'light' | 'dark' | 'auto';\n syntaxTheme: string;\n editor: EditorOptionId;\n colorVision: ColorVisionMode;\n autoViewedPatterns: string[];\n}\n\ninterface SettingsModalProps {\n isOpen: boolean;\n onClose: () => void;\n settings: AppearanceSettings;\n onSettingsChange: (settings: AppearanceSettings) => void;\n}\n\ntype SettingsSection = 'appearance' | 'system';\n\nconst DEFAULT_SETTINGS: AppearanceSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark',\n syntaxTheme: 'vsDark',\n editor: DEFAULT_EDITOR_ID,\n colorVision: 'normal',\n autoViewedPatterns: [],\n};\n\nconst FONT_FAMILIES = [\n {\n name: 'System Font',\n value:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n },\n { name: 'Menlo', value: 'Menlo, Monaco, \"Courier New\", monospace' },\n { name: 'SF Mono', value: 'SF Mono, Consolas, \"Liberation Mono\", monospace' },\n { name: 'Fira Code', value: '\"Fira Code\", \"Courier New\", monospace' },\n { name: 'JetBrains Mono', value: '\"JetBrains Mono\", \"Courier New\", monospace' },\n];\n\nconst COLOR_VISION_MODES = [\n { id: 'normal', label: 'Normal' },\n {\n id: 'deuteranopia',\n label: 'Deuteranopia',\n tooltip: 'Deuteranopia mode uses blue/orange instead of green/red for diffs.',\n },\n] as const;\n\nconst SETTINGS_SECTIONS = [\n {\n id: 'appearance',\n label: 'Appearance',\n },\n {\n id: 'system',\n label: 'System',\n },\n] as const;\n\nexport function SettingsModal({ isOpen, onClose, settings, onSettingsChange }: SettingsModalProps) {\n const [autoViewedPatternsInput, setAutoViewedPatternsInput] = useState(\n formatAutoViewedPatterns(settings.autoViewedPatterns),\n );\n const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');\n const { enableScope, disableScope } = useHotkeysContext();\n\n // Manage scopes when modal opens/closes\n useEffect(() => {\n if (isOpen) {\n // Disable navigation scope when settings modal is open\n disableScope('navigation');\n } else {\n // Re-enable navigation scope when modal closes\n enableScope('navigation');\n }\n\n return () => {\n // Cleanup: ensure navigation scope is enabled\n enableScope('navigation');\n };\n }, [isOpen, enableScope, disableScope]);\n\n // Get current theme (resolve 'auto' to actual theme)\n const getCurrentTheme = (): 'light' | 'dark' => {\n if (settings.theme === 'auto') {\n return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n }\n return settings.theme;\n };\n\n // Get available themes based on current background color\n const getAvailableThemes = () => {\n return getThemesForResolvedTheme(getCurrentTheme());\n };\n\n // Handle theme change and auto-select valid syntax theme\n const handleThemeChange = (theme: 'light' | 'dark' | 'auto') => {\n const newSettings = { ...settings, theme };\n\n // Determine the effective theme\n const effectiveTheme =\n theme === 'auto'\n ? window.matchMedia('(prefers-color-scheme: dark)').matches\n ? 'dark'\n : 'light'\n : theme;\n\n const isCurrentThemeValid = isSyntaxThemeForResolvedTheme(settings.syntaxTheme, effectiveTheme);\n\n // If current theme becomes invalid, auto-select first item\n if (!isCurrentThemeValid) {\n const firstTheme = getFallbackSyntaxTheme(effectiveTheme);\n if (firstTheme) {\n newSettings.syntaxTheme = firstTheme.id;\n }\n }\n\n onSettingsChange(newSettings);\n };\n\n const handleReset = () => {\n if (activeSection === 'appearance') {\n onSettingsChange({\n ...settings,\n fontSize: DEFAULT_SETTINGS.fontSize,\n fontFamily: DEFAULT_SETTINGS.fontFamily,\n theme: DEFAULT_SETTINGS.theme,\n syntaxTheme: DEFAULT_SETTINGS.syntaxTheme,\n colorVision: DEFAULT_SETTINGS.colorVision,\n });\n return;\n }\n\n onSettingsChange({\n ...settings,\n editor: DEFAULT_SETTINGS.editor,\n autoViewedPatterns: DEFAULT_SETTINGS.autoViewedPatterns,\n });\n setAutoViewedPatternsInput(formatAutoViewedPatterns(DEFAULT_SETTINGS.autoViewedPatterns));\n };\n\n if (!isOpen) return null;\n\n return (\n <div className=\"fixed inset-0 flex items-center justify-center z-50 pointer-events-none\">\n <div className=\"bg-github-bg-secondary border border-github-border rounded-lg w-full max-w-3xl mx-4 pointer-events-auto overflow-hidden\">\n <div className=\"flex items-center justify-between p-4 border-b border-github-border\">\n <h2 className=\"text-lg font-semibold text-github-text-primary flex items-center gap-2\">\n <Settings size={20} />\n Settings\n </h2>\n <button\n onClick={onClose}\n className=\"text-github-text-secondary hover:text-github-text-primary p-1\"\n >\n <X size={18} />\n </button>\n </div>\n\n <div className=\"flex flex-col sm:flex-row min-h-[420px]\">\n <nav\n aria-label=\"Settings sections\"\n className=\"sm:w-40 border-b sm:border-b-0 sm:border-r border-github-border px-3 py-2\"\n >\n <div className=\"flex sm:flex-col gap-1\">\n {SETTINGS_SECTIONS.map((section) => {\n const isActive = section.id === activeSection;\n return (\n <button\n key={section.id}\n type=\"button\"\n onClick={() => setActiveSection(section.id)}\n aria-pressed={isActive}\n className={`flex-1 sm:flex-none text-left px-3 py-2 text-sm font-medium transition-colors border-b-2 sm:border-b-0 sm:border-l-2 ${\n isActive\n ? 'text-github-text-primary border-github-accent'\n : 'text-github-text-secondary border-transparent hover:text-github-text-primary'\n }`}\n >\n {section.label}\n </button>\n );\n })}\n </div>\n </nav>\n\n <div className=\"flex-1 p-4 sm:p-6 overflow-y-auto\">\n {activeSection === 'appearance' && (\n <div className=\"space-y-6\">\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Font Size\n </label>\n <div className=\"flex items-center gap-3\">\n <input\n type=\"range\"\n min=\"10\"\n max=\"20\"\n step=\"1\"\n value={settings.fontSize}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n fontSize: Number.parseInt(e.target.value, 10),\n })\n }\n className=\"flex-1 accent-github-accent\"\n />\n <span className=\"text-sm text-github-text-secondary w-8 text-right\">\n {settings.fontSize}px\n </span>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Font Family\n </label>\n <select\n value={settings.fontFamily}\n onChange={(e) => onSettingsChange({ ...settings, fontFamily: e.target.value })}\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {FONT_FAMILIES.map((font) => (\n <option key={font.value} value={font.value}>\n {font.name}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Theme\n </label>\n <div className=\"flex gap-2\">\n {(['light', 'dark', 'auto'] as const).map((theme) => (\n <button\n key={theme}\n type=\"button\"\n onClick={() => handleThemeChange(theme)}\n className={`px-3 py-2 text-sm rounded border transition-colors ${\n settings.theme === theme\n ? 'bg-github-accent text-white border-github-accent'\n : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'\n }`}\n >\n {theme.charAt(0).toUpperCase() + theme.slice(1)}\n </button>\n ))}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Color Vision\n </label>\n <div className=\"flex gap-2\">\n {COLOR_VISION_MODES.map((mode) => {\n const isSelected = (settings.colorVision ?? 'normal') === mode.id;\n const button = (\n <button\n key={mode.id}\n type=\"button\"\n onClick={() => onSettingsChange({ ...settings, colorVision: mode.id })}\n className={`px-3 py-2 text-sm rounded border transition-colors ${\n isSelected\n ? 'bg-github-accent text-white border-github-accent'\n : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'\n }`}\n >\n {mode.label}\n </button>\n );\n\n if (!('tooltip' in mode)) {\n return button;\n }\n\n return (\n <Tooltip key={mode.id} content={mode.tooltip}>\n {button}\n </Tooltip>\n );\n })}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Syntax Highlighting Theme\n </label>\n <select\n value={settings.syntaxTheme}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n syntaxTheme: e.target.value,\n })\n }\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {getAvailableThemes().map((theme) => (\n <option key={theme.id} value={theme.id}>\n {theme.label}\n </option>\n ))}\n </select>\n </div>\n </div>\n )}\n\n {activeSection === 'system' && (\n <div className=\"space-y-6\">\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Open In Editor\n </label>\n <select\n value={settings.editor}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n editor: e.target.value as AppearanceSettings['editor'],\n })\n }\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {EDITOR_OPTIONS.map((editor) => (\n <option key={editor.id} value={editor.id}>\n {editor.label}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label\n htmlFor=\"auto-viewed-patterns\"\n className=\"block text-sm font-medium text-github-text-primary mb-2\"\n >\n Auto-Mark Viewed Patterns\n </label>\n <p className=\"text-sm text-github-text-secondary mb-2\">\n Files matching these glob patterns are marked as Viewed automatically. Enter one\n pattern per line.\n </p>\n <textarea\n id=\"auto-viewed-patterns\"\n value={autoViewedPatternsInput}\n onChange={(e) => {\n setAutoViewedPatternsInput(e.target.value);\n onSettingsChange({\n ...settings,\n autoViewedPatterns: parseAutoViewedPatterns(e.target.value),\n });\n }}\n rows={6}\n spellCheck={false}\n placeholder={'*.test.ts\\n*.stories.tsx\\nsrc/generated/**'}\n className=\"w-full p-3 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm font-mono\"\n />\n </div>\n </div>\n )}\n </div>\n </div>\n\n <div className=\"flex items-center justify-between p-4 border-t border-github-border\">\n <button\n onClick={handleReset}\n className=\"px-3 py-2 text-sm text-github-text-secondary hover:text-github-text-primary\"\n >\n {activeSection === 'appearance' ? 'Reset Appearance Defaults' : 'Reset System Defaults'}\n </button>\n <button\n onClick={onClose}\n className=\"px-4 py-2 text-sm bg-github-accent text-white rounded hover:bg-green-600\"\n >\n Close\n </button>\n </div>\n </div>\n </div>\n );\n}\n\nexport type { AppearanceSettings };\n","a72112f:src/client/components/SettingsModal.tsx":"import { Settings, X } from 'lucide-react';\nimport { useState, useEffect } from 'react';\nimport { useHotkeysContext } from 'react-hotkeys-hook';\n\nimport { DEFAULT_EDITOR_ID, EDITOR_OPTIONS, type EditorOptionId } from '../../utils/editorOptions';\nimport { type ScrollAnimationSetting } from '../hooks/usePreferredScrollBehavior';\nimport type { ColorVisionMode } from '../utils/appearanceTheme';\nimport { formatAutoViewedPatterns, parseAutoViewedPatterns } from '../utils/autoViewedPatterns';\nimport {\n getFallbackSyntaxTheme,\n getThemesForResolvedTheme,\n isSyntaxThemeForResolvedTheme,\n} from '../utils/themeLoader';\nimport { Tooltip } from './Tooltip';\n\ninterface AppearanceSettings {\n fontSize: number;\n fontFamily: string;\n theme: 'light' | 'dark' | 'auto';\n syntaxTheme: string;\n editor: EditorOptionId;\n colorVision: ColorVisionMode;\n scrollAnimation: ScrollAnimationSetting;\n autoViewedPatterns: string[];\n}\n\ninterface SettingsModalProps {\n isOpen: boolean;\n onClose: () => void;\n settings: AppearanceSettings;\n onSettingsChange: (settings: AppearanceSettings) => void;\n}\n\ntype SettingsSection = 'appearance' | 'system';\n\nconst DEFAULT_SETTINGS: AppearanceSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark',\n syntaxTheme: 'vsDark',\n editor: DEFAULT_EDITOR_ID,\n colorVision: 'normal',\n scrollAnimation: 'auto',\n autoViewedPatterns: [],\n};\n\nconst FONT_FAMILIES = [\n {\n name: 'System Font',\n value:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n },\n { name: 'Menlo', value: 'Menlo, Monaco, \"Courier New\", monospace' },\n { name: 'SF Mono', value: 'SF Mono, Consolas, \"Liberation Mono\", monospace' },\n { name: 'Fira Code', value: '\"Fira Code\", \"Courier New\", monospace' },\n { name: 'JetBrains Mono', value: '\"JetBrains Mono\", \"Courier New\", monospace' },\n];\n\nconst COLOR_VISION_MODES = [\n { id: 'normal', label: 'Normal' },\n {\n id: 'deuteranopia',\n label: 'Deuteranopia',\n tooltip: 'Deuteranopia mode uses blue/orange instead of green/red for diffs.',\n },\n] as const;\n\nconst SCROLL_ANIMATION_MODES = [\n {\n id: 'auto',\n label: 'Auto',\n tooltip: 'Follows the OS prefers-reduced-motion setting.',\n },\n { id: 'enabled', label: 'Enabled' },\n { id: 'disabled', label: 'Disabled' },\n] as const;\n\nconst SETTINGS_SECTIONS = [\n {\n id: 'appearance',\n label: 'Appearance',\n },\n {\n id: 'system',\n label: 'System',\n },\n] as const;\n\nexport function SettingsModal({ isOpen, onClose, settings, onSettingsChange }: SettingsModalProps) {\n const [autoViewedPatternsInput, setAutoViewedPatternsInput] = useState(\n formatAutoViewedPatterns(settings.autoViewedPatterns),\n );\n const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');\n const { enableScope, disableScope } = useHotkeysContext();\n\n // Manage scopes when modal opens/closes\n useEffect(() => {\n if (isOpen) {\n // Disable navigation scope when settings modal is open\n disableScope('navigation');\n } else {\n // Re-enable navigation scope when modal closes\n enableScope('navigation');\n }\n\n return () => {\n // Cleanup: ensure navigation scope is enabled\n enableScope('navigation');\n };\n }, [isOpen, enableScope, disableScope]);\n\n // Get current theme (resolve 'auto' to actual theme)\n const getCurrentTheme = (): 'light' | 'dark' => {\n if (settings.theme === 'auto') {\n return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n }\n return settings.theme;\n };\n\n // Get available themes based on current background color\n const getAvailableThemes = () => {\n return getThemesForResolvedTheme(getCurrentTheme());\n };\n\n // Handle theme change and auto-select valid syntax theme\n const handleThemeChange = (theme: 'light' | 'dark' | 'auto') => {\n const newSettings = { ...settings, theme };\n\n // Determine the effective theme\n const effectiveTheme =\n theme === 'auto'\n ? window.matchMedia('(prefers-color-scheme: dark)').matches\n ? 'dark'\n : 'light'\n : theme;\n\n const isCurrentThemeValid = isSyntaxThemeForResolvedTheme(settings.syntaxTheme, effectiveTheme);\n\n // If current theme becomes invalid, auto-select first item\n if (!isCurrentThemeValid) {\n const firstTheme = getFallbackSyntaxTheme(effectiveTheme);\n if (firstTheme) {\n newSettings.syntaxTheme = firstTheme.id;\n }\n }\n\n onSettingsChange(newSettings);\n };\n\n const handleReset = () => {\n if (activeSection === 'appearance') {\n onSettingsChange({\n ...settings,\n fontSize: DEFAULT_SETTINGS.fontSize,\n fontFamily: DEFAULT_SETTINGS.fontFamily,\n theme: DEFAULT_SETTINGS.theme,\n syntaxTheme: DEFAULT_SETTINGS.syntaxTheme,\n colorVision: DEFAULT_SETTINGS.colorVision,\n scrollAnimation: DEFAULT_SETTINGS.scrollAnimation,\n });\n return;\n }\n\n onSettingsChange({\n ...settings,\n editor: DEFAULT_SETTINGS.editor,\n autoViewedPatterns: DEFAULT_SETTINGS.autoViewedPatterns,\n });\n setAutoViewedPatternsInput(formatAutoViewedPatterns(DEFAULT_SETTINGS.autoViewedPatterns));\n };\n\n if (!isOpen) return null;\n\n return (\n <div className=\"fixed inset-0 flex items-center justify-center z-50 pointer-events-none\">\n <div className=\"bg-github-bg-secondary border border-github-border rounded-lg w-full max-w-3xl mx-4 pointer-events-auto overflow-hidden\">\n <div className=\"flex items-center justify-between p-4 border-b border-github-border\">\n <h2 className=\"text-lg font-semibold text-github-text-primary flex items-center gap-2\">\n <Settings size={20} />\n Settings\n </h2>\n <button\n onClick={onClose}\n className=\"text-github-text-secondary hover:text-github-text-primary p-1\"\n >\n <X size={18} />\n </button>\n </div>\n\n <div className=\"flex flex-col sm:flex-row min-h-[420px]\">\n <nav\n aria-label=\"Settings sections\"\n className=\"sm:w-40 border-b sm:border-b-0 sm:border-r border-github-border px-3 py-2\"\n >\n <div className=\"flex sm:flex-col gap-1\">\n {SETTINGS_SECTIONS.map((section) => {\n const isActive = section.id === activeSection;\n return (\n <button\n key={section.id}\n type=\"button\"\n onClick={() => setActiveSection(section.id)}\n aria-pressed={isActive}\n className={`flex-1 sm:flex-none text-left px-3 py-2 text-sm font-medium transition-colors border-b-2 sm:border-b-0 sm:border-l-2 ${\n isActive\n ? 'text-github-text-primary border-github-accent'\n : 'text-github-text-secondary border-transparent hover:text-github-text-primary'\n }`}\n >\n {section.label}\n </button>\n );\n })}\n </div>\n </nav>\n\n <div className=\"flex-1 p-4 sm:p-6 overflow-y-auto\">\n {activeSection === 'appearance' && (\n <div className=\"space-y-6\">\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Font Size\n </label>\n <div className=\"flex items-center gap-3\">\n <input\n type=\"range\"\n min=\"10\"\n max=\"20\"\n step=\"1\"\n value={settings.fontSize}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n fontSize: Number.parseInt(e.target.value, 10),\n })\n }\n className=\"flex-1 accent-github-accent\"\n />\n <span className=\"text-sm text-github-text-secondary w-8 text-right\">\n {settings.fontSize}px\n </span>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Font Family\n </label>\n <select\n value={settings.fontFamily}\n onChange={(e) => onSettingsChange({ ...settings, fontFamily: e.target.value })}\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {FONT_FAMILIES.map((font) => (\n <option key={font.value} value={font.value}>\n {font.name}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Theme\n </label>\n <div className=\"flex gap-2\">\n {(['light', 'dark', 'auto'] as const).map((theme) => (\n <button\n key={theme}\n type=\"button\"\n onClick={() => handleThemeChange(theme)}\n className={`px-3 py-2 text-sm rounded border transition-colors ${\n settings.theme === theme\n ? 'bg-github-accent text-white border-github-accent'\n : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'\n }`}\n >\n {theme.charAt(0).toUpperCase() + theme.slice(1)}\n </button>\n ))}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Color Vision\n </label>\n <div className=\"flex gap-2\">\n {COLOR_VISION_MODES.map((mode) => {\n const isSelected = (settings.colorVision ?? 'normal') === mode.id;\n const button = (\n <button\n key={mode.id}\n type=\"button\"\n onClick={() => onSettingsChange({ ...settings, colorVision: mode.id })}\n className={`px-3 py-2 text-sm rounded border transition-colors ${\n isSelected\n ? 'bg-github-accent text-white border-github-accent'\n : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'\n }`}\n >\n {mode.label}\n </button>\n );\n\n if (!('tooltip' in mode)) {\n return button;\n }\n\n return (\n <Tooltip key={mode.id} content={mode.tooltip}>\n {button}\n </Tooltip>\n );\n })}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Scroll Animation\n </label>\n <div className=\"flex gap-2\">\n {SCROLL_ANIMATION_MODES.map((mode) => {\n const isSelected = (settings.scrollAnimation ?? 'auto') === mode.id;\n const button = (\n <button\n key={mode.id}\n type=\"button\"\n onClick={() =>\n onSettingsChange({ ...settings, scrollAnimation: mode.id })\n }\n className={`px-3 py-2 text-sm rounded border transition-colors ${\n isSelected\n ? 'bg-github-accent text-white border-github-accent'\n : 'bg-github-bg-tertiary text-github-text-secondary border-github-border hover:text-github-text-primary'\n }`}\n >\n {mode.label}\n </button>\n );\n\n if (!('tooltip' in mode)) {\n return button;\n }\n\n return (\n <Tooltip key={mode.id} content={mode.tooltip}>\n {button}\n </Tooltip>\n );\n })}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Syntax Highlighting Theme\n </label>\n <select\n value={settings.syntaxTheme}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n syntaxTheme: e.target.value,\n })\n }\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {getAvailableThemes().map((theme) => (\n <option key={theme.id} value={theme.id}>\n {theme.label}\n </option>\n ))}\n </select>\n </div>\n </div>\n )}\n\n {activeSection === 'system' && (\n <div className=\"space-y-6\">\n <div>\n <label className=\"block text-sm font-medium text-github-text-primary mb-2\">\n Open In Editor\n </label>\n <select\n value={settings.editor}\n onChange={(e) =>\n onSettingsChange({\n ...settings,\n editor: e.target.value as AppearanceSettings['editor'],\n })\n }\n className=\"w-full p-2 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm\"\n >\n {EDITOR_OPTIONS.map((editor) => (\n <option key={editor.id} value={editor.id}>\n {editor.label}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label\n htmlFor=\"auto-viewed-patterns\"\n className=\"block text-sm font-medium text-github-text-primary mb-2\"\n >\n Auto-Mark Viewed Patterns\n </label>\n <p className=\"text-sm text-github-text-secondary mb-2\">\n Files matching these glob patterns are marked as Viewed automatically. Enter one\n pattern per line.\n </p>\n <textarea\n id=\"auto-viewed-patterns\"\n value={autoViewedPatternsInput}\n onChange={(e) => {\n setAutoViewedPatternsInput(e.target.value);\n onSettingsChange({\n ...settings,\n autoViewedPatterns: parseAutoViewedPatterns(e.target.value),\n });\n }}\n rows={6}\n spellCheck={false}\n placeholder={'*.test.ts\\n*.stories.tsx\\nsrc/generated/**'}\n className=\"w-full p-3 bg-github-bg-tertiary border border-github-border rounded text-github-text-primary text-sm font-mono\"\n />\n </div>\n </div>\n )}\n </div>\n </div>\n\n <div className=\"flex items-center justify-between p-4 border-t border-github-border\">\n <button\n onClick={handleReset}\n className=\"px-3 py-2 text-sm text-github-text-secondary hover:text-github-text-primary\"\n >\n {activeSection === 'appearance' ? 'Reset Appearance Defaults' : 'Reset System Defaults'}\n </button>\n <button\n onClick={onClose}\n className=\"px-4 py-2 text-sm bg-github-accent text-white rounded hover:bg-green-600\"\n >\n Close\n </button>\n </div>\n </div>\n </div>\n );\n}\n\nexport type { AppearanceSettings };\n","7d40fd4:src/client/hooks/useAppearanceSettings.test.ts":"import { act, renderHook, waitFor } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { APPEARANCE_STORAGE_KEY } from '../utils/appearanceTheme';\n\nimport { useAppearanceSettings } from './useAppearanceSettings';\n\nconst setMatchMedia = (initialMatches: boolean) => {\n let matches = initialMatches;\n const listeners = new Set<(event: MediaQueryListEvent) => void>();\n\n const mediaQueryList = {\n get matches() {\n return matches;\n },\n media: '(prefers-color-scheme: dark)',\n onchange: null,\n addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.add(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n removeEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.delete(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n addListener: vi.fn(),\n removeListener: vi.fn(),\n dispatchEvent: vi.fn(),\n } as MediaQueryList;\n\n Object.defineProperty(window, 'matchMedia', {\n configurable: true,\n writable: true,\n value: vi.fn(() => mediaQueryList),\n });\n\n return {\n setMatches(nextMatches: boolean) {\n matches = nextMatches;\n const event = { matches: nextMatches, media: mediaQueryList.media } as MediaQueryListEvent;\n listeners.forEach((listener) => listener(event));\n },\n };\n};\n\ndescribe('useAppearanceSettings', () => {\n beforeEach(() => {\n localStorage.clear();\n document.documentElement.removeAttribute('data-color-vision');\n document.documentElement.removeAttribute('data-theme');\n document.documentElement.removeAttribute('style');\n document.body.removeAttribute('style');\n });\n\n it('updates syntax highlighting theme when auto theme follows OS changes', async () => {\n localStorage.setItem(\n APPEARANCE_STORAGE_KEY,\n JSON.stringify({\n theme: 'auto',\n syntaxTheme: 'vsDark',\n }),\n );\n const matchMedia = setMatchMedia(true);\n\n const { result } = renderHook(() => useAppearanceSettings());\n\n await waitFor(() => {\n expect(document.documentElement.getAttribute('data-theme')).toBe('dark');\n expect(result.current.settings.syntaxTheme).toBe('vsDark');\n });\n\n act(() => {\n matchMedia.setMatches(false);\n });\n\n await waitFor(() => {\n expect(document.documentElement.getAttribute('data-theme')).toBe('light');\n expect(result.current.settings.syntaxTheme).toBe('github');\n });\n\n expect(JSON.parse(localStorage.getItem(APPEARANCE_STORAGE_KEY) ?? '{}')).toMatchObject({\n theme: 'auto',\n syntaxTheme: 'github',\n });\n });\n});\n","a72112f:src/client/hooks/useAppearanceSettings.test.ts":"import { act, renderHook, waitFor } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { APPEARANCE_STORAGE_KEY } from '../utils/appearanceTheme';\n\nimport { useAppearanceSettings } from './useAppearanceSettings';\nimport { usePreferredScrollBehavior } from './usePreferredScrollBehavior';\n\nvi.mock('./usePreferredScrollBehavior', () => ({\n usePreferredScrollBehavior: vi.fn(),\n}));\n\nconst mockedUsePreferredScrollBehavior = vi.mocked(usePreferredScrollBehavior);\n\nconst setMatchMedia = (initialMatches: boolean) => {\n let matches = initialMatches;\n const listeners = new Set<(event: MediaQueryListEvent) => void>();\n\n const mediaQueryList = {\n get matches() {\n return matches;\n },\n media: '(prefers-color-scheme: dark)',\n onchange: null,\n addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.add(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n removeEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.delete(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n addListener: vi.fn(),\n removeListener: vi.fn(),\n dispatchEvent: vi.fn(),\n } as MediaQueryList;\n\n Object.defineProperty(window, 'matchMedia', {\n configurable: true,\n writable: true,\n value: vi.fn(() => mediaQueryList),\n });\n\n return {\n setMatches(nextMatches: boolean) {\n matches = nextMatches;\n const event = { matches: nextMatches, media: mediaQueryList.media } as MediaQueryListEvent;\n listeners.forEach((listener) => listener(event));\n },\n };\n};\n\ndescribe('useAppearanceSettings', () => {\n beforeEach(() => {\n localStorage.clear();\n });\n\n describe('theme', () => {\n beforeEach(() => {\n document.documentElement.removeAttribute('data-color-vision');\n document.documentElement.removeAttribute('data-theme');\n document.documentElement.removeAttribute('style');\n document.body.removeAttribute('style');\n });\n\n it('updates syntax highlighting theme when auto theme follows OS changes', async () => {\n localStorage.setItem(\n APPEARANCE_STORAGE_KEY,\n JSON.stringify({\n theme: 'auto',\n syntaxTheme: 'vsDark',\n }),\n );\n const matchMedia = setMatchMedia(true);\n\n const { result } = renderHook(() => useAppearanceSettings());\n\n await waitFor(() => {\n expect(document.documentElement.getAttribute('data-theme')).toBe('dark');\n expect(result.current.settings.syntaxTheme).toBe('vsDark');\n });\n\n act(() => {\n matchMedia.setMatches(false);\n });\n\n await waitFor(() => {\n expect(document.documentElement.getAttribute('data-theme')).toBe('light');\n expect(result.current.settings.syntaxTheme).toBe('github');\n });\n\n expect(JSON.parse(localStorage.getItem(APPEARANCE_STORAGE_KEY) ?? '{}')).toMatchObject({\n theme: 'auto',\n syntaxTheme: 'github',\n });\n });\n });\n\n describe('scrollBehavior', () => {\n beforeEach(() => {\n mockedUsePreferredScrollBehavior.mockReset();\n mockedUsePreferredScrollBehavior.mockReturnValue('smooth');\n setMatchMedia(false);\n });\n\n it(\"defaults scrollAnimation to 'auto' and forwards it to usePreferredScrollBehavior\", () => {\n const { result } = renderHook(() => useAppearanceSettings());\n\n expect(result.current.settings.scrollAnimation).toBe('auto');\n expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('auto');\n });\n\n it('forwards scrollAnimation loaded from localStorage to usePreferredScrollBehavior', () => {\n localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify({ scrollAnimation: 'enabled' }));\n const { result } = renderHook(() => useAppearanceSettings());\n\n expect(result.current.settings.scrollAnimation).toBe('enabled');\n expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('enabled');\n });\n\n it('returns whatever usePreferredScrollBehavior resolves', () => {\n mockedUsePreferredScrollBehavior.mockReturnValue('instant');\n const { result } = renderHook(() => useAppearanceSettings());\n\n expect(result.current.scrollBehavior).toBe('instant');\n });\n\n it('persists updated scrollAnimation and forwards the new value to usePreferredScrollBehavior', () => {\n const { result } = renderHook(() => useAppearanceSettings());\n\n act(() => {\n result.current.updateSettings({\n ...result.current.settings,\n scrollAnimation: 'disabled',\n });\n });\n\n expect(result.current.settings.scrollAnimation).toBe('disabled');\n expect(mockedUsePreferredScrollBehavior).toHaveBeenLastCalledWith('disabled');\n expect(JSON.parse(localStorage.getItem(APPEARANCE_STORAGE_KEY) ?? '{}')).toMatchObject({\n scrollAnimation: 'disabled',\n });\n });\n });\n});\n","7d40fd4:src/client/hooks/useAppearanceSettings.ts":"import { useState, useEffect, useCallback } from 'react';\n\nimport { DEFAULT_EDITOR_ID } from '../../utils/editorOptions';\nimport type { AppearanceSettings } from '../components/SettingsModal';\nimport { normalizeAutoViewedPatterns } from '../utils/autoViewedPatterns';\nimport {\n APPEARANCE_STORAGE_KEY,\n applyResolvedTheme,\n resolveThemePreference,\n type ColorVisionMode,\n type ResolvedTheme,\n} from '../utils/appearanceTheme';\nimport { getFallbackSyntaxTheme, isSyntaxThemeForResolvedTheme } from '../utils/themeLoader';\n\nconst DEFAULT_SETTINGS: AppearanceSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark',\n syntaxTheme: 'vsDark',\n editor: DEFAULT_EDITOR_ID,\n colorVision: 'normal',\n autoViewedPatterns: [],\n};\n\nexport function useAppearanceSettings() {\n const [settings, setSettings] = useState<AppearanceSettings>(() => {\n try {\n const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY);\n if (stored) {\n const parsed = JSON.parse(stored) as Partial<AppearanceSettings> & {\n autoViewedPatterns?: unknown;\n };\n\n return {\n ...DEFAULT_SETTINGS,\n ...parsed,\n autoViewedPatterns: normalizeAutoViewedPatterns(parsed.autoViewedPatterns),\n };\n }\n } catch (error) {\n console.warn('Failed to load appearance settings from localStorage:', error);\n }\n return DEFAULT_SETTINGS;\n });\n\n const applyTheme = useCallback(\n (theme: 'light' | 'dark', colorVision: ColorVisionMode = 'normal') => {\n applyResolvedTheme(theme, colorVision);\n },\n [],\n );\n\n const saveSettings = useCallback((newSettings: AppearanceSettings) => {\n try {\n localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(newSettings));\n } catch (error) {\n console.warn('Failed to save appearance settings to localStorage:', error);\n }\n }, []);\n\n const getSettingsForResolvedTheme = useCallback(\n (currentSettings: AppearanceSettings, resolvedTheme: ResolvedTheme) => {\n if (isSyntaxThemeForResolvedTheme(currentSettings.syntaxTheme, resolvedTheme)) {\n return currentSettings;\n }\n\n const fallbackSyntaxTheme = getFallbackSyntaxTheme(resolvedTheme);\n if (!fallbackSyntaxTheme) {\n return currentSettings;\n }\n\n return {\n ...currentSettings,\n syntaxTheme: fallbackSyntaxTheme.id,\n };\n },\n [],\n );\n\n // Apply settings to document\n useEffect(() => {\n const root = document.documentElement;\n\n // Apply font size\n root.style.setProperty('--app-font-size', `${settings.fontSize}px`);\n\n // Apply font family\n root.style.setProperty('--app-font-family', settings.fontFamily);\n\n // Apply theme\n const colorVision = settings.colorVision ?? 'normal';\n const applyResolvedAppearance = (resolvedTheme: ResolvedTheme) => {\n applyTheme(resolvedTheme, colorVision);\n\n const nextSettings = getSettingsForResolvedTheme(settings, resolvedTheme);\n if (nextSettings !== settings) {\n setSettings(nextSettings);\n saveSettings(nextSettings);\n }\n };\n\n if (settings.theme === 'auto') {\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n applyResolvedAppearance(\n resolveThemePreference('auto', mediaQuery.matches ? 'dark' : 'light'),\n );\n\n const handleChange = (e: MediaQueryListEvent) => {\n applyResolvedAppearance(resolveThemePreference('auto', e.matches ? 'dark' : 'light'));\n };\n\n mediaQuery.addEventListener('change', handleChange);\n return () => mediaQuery.removeEventListener('change', handleChange);\n } else {\n applyResolvedAppearance(settings.theme);\n return undefined;\n }\n }, [settings, applyTheme, getSettingsForResolvedTheme, saveSettings]);\n\n const updateSettings = (newSettings: AppearanceSettings) => {\n setSettings(newSettings);\n saveSettings(newSettings);\n };\n\n return {\n settings,\n updateSettings,\n };\n}\n","a72112f:src/client/hooks/useAppearanceSettings.ts":"import { useState, useEffect, useCallback, useMemo } from 'react';\n\nimport { DEFAULT_EDITOR_ID } from '../../utils/editorOptions';\nimport type { AppearanceSettings } from '../components/SettingsModal';\nimport { normalizeAutoViewedPatterns } from '../utils/autoViewedPatterns';\nimport {\n APPEARANCE_STORAGE_KEY,\n applyResolvedTheme,\n resolveThemePreference,\n type ColorVisionMode,\n type ResolvedTheme,\n} from '../utils/appearanceTheme';\nimport { getFallbackSyntaxTheme, isSyntaxThemeForResolvedTheme } from '../utils/themeLoader';\nimport { usePreferredScrollBehavior } from './usePreferredScrollBehavior';\n\nconst DEFAULT_SETTINGS: AppearanceSettings = {\n fontSize: 14,\n fontFamily:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Helvetica, Arial, sans-serif',\n theme: 'dark',\n syntaxTheme: 'vsDark',\n editor: DEFAULT_EDITOR_ID,\n colorVision: 'normal',\n scrollAnimation: 'auto',\n autoViewedPatterns: [],\n};\n\ninterface UseAppearanceSettingsReturn {\n settings: AppearanceSettings;\n updateSettings: (newSettings: AppearanceSettings) => void;\n readonly scrollBehavior: ScrollBehavior;\n}\n\nexport function useAppearanceSettings(): UseAppearanceSettingsReturn {\n const [settings, setSettings] = useState<AppearanceSettings>(() => {\n try {\n const stored = localStorage.getItem(APPEARANCE_STORAGE_KEY);\n if (stored) {\n const parsed = JSON.parse(stored) as Partial<AppearanceSettings> & {\n autoViewedPatterns?: unknown;\n };\n\n return {\n ...DEFAULT_SETTINGS,\n ...parsed,\n autoViewedPatterns: normalizeAutoViewedPatterns(parsed.autoViewedPatterns),\n };\n }\n } catch (error) {\n console.warn('Failed to load appearance settings from localStorage:', error);\n }\n return DEFAULT_SETTINGS;\n });\n\n const applyTheme = useCallback(\n (theme: 'light' | 'dark', colorVision: ColorVisionMode = 'normal') => {\n applyResolvedTheme(theme, colorVision);\n },\n [],\n );\n\n const saveSettings = useCallback((newSettings: AppearanceSettings) => {\n try {\n localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(newSettings));\n } catch (error) {\n console.warn('Failed to save appearance settings to localStorage:', error);\n }\n }, []);\n\n const getSettingsForResolvedTheme = useCallback(\n (currentSettings: AppearanceSettings, resolvedTheme: ResolvedTheme) => {\n if (isSyntaxThemeForResolvedTheme(currentSettings.syntaxTheme, resolvedTheme)) {\n return currentSettings;\n }\n\n const fallbackSyntaxTheme = getFallbackSyntaxTheme(resolvedTheme);\n if (!fallbackSyntaxTheme) {\n return currentSettings;\n }\n\n return {\n ...currentSettings,\n syntaxTheme: fallbackSyntaxTheme.id,\n };\n },\n [],\n );\n\n // Apply settings to document\n useEffect(() => {\n const root = document.documentElement;\n\n // Apply font size\n root.style.setProperty('--app-font-size', `${settings.fontSize}px`);\n\n // Apply font family\n root.style.setProperty('--app-font-family', settings.fontFamily);\n\n // Apply theme\n const colorVision = settings.colorVision ?? 'normal';\n const applyResolvedAppearance = (resolvedTheme: ResolvedTheme) => {\n applyTheme(resolvedTheme, colorVision);\n\n const nextSettings = getSettingsForResolvedTheme(settings, resolvedTheme);\n if (nextSettings !== settings) {\n setSettings(nextSettings);\n saveSettings(nextSettings);\n }\n };\n\n if (settings.theme === 'auto') {\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n applyResolvedAppearance(\n resolveThemePreference('auto', mediaQuery.matches ? 'dark' : 'light'),\n );\n\n const handleChange = (e: MediaQueryListEvent) => {\n applyResolvedAppearance(resolveThemePreference('auto', e.matches ? 'dark' : 'light'));\n };\n\n mediaQuery.addEventListener('change', handleChange);\n return () => mediaQuery.removeEventListener('change', handleChange);\n } else {\n applyResolvedAppearance(settings.theme);\n return undefined;\n }\n }, [settings, applyTheme, getSettingsForResolvedTheme, saveSettings]);\n\n const updateSettings = useCallback(\n (newSettings: AppearanceSettings) => {\n setSettings(newSettings);\n saveSettings(newSettings);\n },\n [saveSettings],\n );\n\n const scrollBehavior = usePreferredScrollBehavior(settings.scrollAnimation);\n\n return useMemo(\n () => ({ settings, updateSettings, scrollBehavior }),\n [settings, updateSettings, scrollBehavior],\n );\n}\n","7d40fd4:src/client/hooks/useLazyDiffRendering.ts":"import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { type DiffResponse } from '../../types/diff';\nimport { getFileElementId } from '../utils/domUtils';\n\nconst INITIAL_RENDERED_FILE_COUNT = 8;\nconst LAZY_RENDER_ROOT_MARGIN = '1200px 0px';\nconst SIDEBAR_SCROLL_MAX_ATTEMPTS = 60;\nconst SIDEBAR_SCROLL_CORRECTION_DELAY_MS = 180;\n\ninterface UseLazyDiffRenderingOptions {\n diffData: DiffResponse | null;\n diffScrollContainerRef: React.RefObject<HTMLElement | null>;\n setDiffData: React.Dispatch<React.SetStateAction<DiffResponse | null>>;\n}\n\ninterface UseLazyDiffRenderingReturn {\n renderedFilePaths: Set<string>;\n ensureFileRendered: (filePath: string) => void;\n ensureFilesRenderedUpTo: (filePath: string) => void;\n registerLazyFileContainer: (filePath: string, node: HTMLDivElement | null) => void;\n scrollFileIntoDiffContainer: (filePath: string) => void;\n}\n\nexport function useLazyDiffRendering({\n diffData,\n diffScrollContainerRef,\n setDiffData,\n}: UseLazyDiffRenderingOptions): UseLazyDiffRenderingReturn {\n const [renderedFilePaths, setRenderedFilePaths] = useState<Set<string>>(new Set());\n const renderedFilePathsRef = useRef<Set<string>>(new Set());\n const lazyFileObserverRef = useRef<IntersectionObserver | null>(null);\n const lazyFileNodesRef = useRef<Map<string, HTMLDivElement>>(new Map());\n const generatedStatusCheckedRef = useRef<Set<string>>(new Set());\n const renderedRevisionKeyRef = useRef<string | null>(null);\n const scrollRequestIdRef = useRef(0);\n\n useEffect(() => {\n if (!diffData) {\n const nextPaths = new Set<string>();\n renderedFilePathsRef.current = nextPaths;\n setRenderedFilePaths(nextPaths);\n generatedStatusCheckedRef.current.clear();\n renderedRevisionKeyRef.current = null;\n return;\n }\n\n const revisionKey = `${diffData.requestedBaseCommitish ?? ''}:${diffData.requestedTargetCommitish ?? ''}:${diffData.requestedBaseMode ?? 'direct'}`;\n if (renderedRevisionKeyRef.current === revisionKey) {\n return;\n }\n renderedRevisionKeyRef.current = revisionKey;\n\n const initialPaths = diffData.files\n .slice(0, INITIAL_RENDERED_FILE_COUNT)\n .map((file) => file.path);\n const nextPaths = new Set(initialPaths);\n renderedFilePathsRef.current = nextPaths;\n setRenderedFilePaths(nextPaths);\n generatedStatusCheckedRef.current.clear();\n }, [diffData]);\n\n const ensureFileRendered = useCallback((filePath: string) => {\n const node = lazyFileNodesRef.current.get(filePath);\n if (node && lazyFileObserverRef.current) {\n lazyFileObserverRef.current.unobserve(node);\n }\n\n setRenderedFilePaths((prev) => {\n if (prev.has(filePath)) return prev;\n const next = new Set(prev);\n next.add(filePath);\n renderedFilePathsRef.current = next;\n return next;\n });\n }, []);\n\n const registerLazyFileContainer = useCallback((filePath: string, node: HTMLDivElement | null) => {\n const observer = lazyFileObserverRef.current;\n const previousNode = lazyFileNodesRef.current.get(filePath);\n if (previousNode && observer) {\n observer.unobserve(previousNode);\n }\n\n if (!node) {\n lazyFileNodesRef.current.delete(filePath);\n return;\n }\n\n lazyFileNodesRef.current.set(filePath, node);\n if (renderedFilePathsRef.current.has(filePath) || !observer) {\n return;\n }\n observer.observe(node);\n }, []);\n\n useEffect(() => {\n if (lazyFileObserverRef.current) {\n lazyFileObserverRef.current.disconnect();\n }\n\n if (!diffData) {\n lazyFileObserverRef.current = null;\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n setRenderedFilePaths((prev) => {\n let changed = false;\n const next = new Set(prev);\n\n entries.forEach((entry) => {\n if (!entry.isIntersecting) return;\n const path = (entry.target as HTMLElement).dataset.filePath;\n if (!path || next.has(path)) return;\n next.add(path);\n observer.unobserve(entry.target);\n changed = true;\n });\n\n if (changed) {\n renderedFilePathsRef.current = next;\n }\n return changed ? next : prev;\n });\n },\n {\n root: diffScrollContainerRef.current,\n rootMargin: LAZY_RENDER_ROOT_MARGIN,\n },\n );\n\n lazyFileObserverRef.current = observer;\n lazyFileNodesRef.current.forEach((node, filePath) => {\n if (!renderedFilePathsRef.current.has(filePath)) {\n observer.observe(node);\n }\n });\n\n return () => {\n observer.disconnect();\n lazyFileObserverRef.current = null;\n };\n }, [diffData, diffScrollContainerRef]);\n\n const ensureFilesRenderedUpTo = useCallback(\n (filePath: string) => {\n if (!diffData) return;\n const targetIndex = diffData.files.findIndex((file) => file.path === filePath);\n if (targetIndex < 0) return;\n\n const observer = lazyFileObserverRef.current;\n\n setRenderedFilePaths((prev) => {\n let changed = false;\n const next = new Set(prev);\n for (let i = 0; i <= targetIndex; i++) {\n const path = diffData.files[i]?.path;\n if (path && !next.has(path)) {\n next.add(path);\n changed = true;\n const node = lazyFileNodesRef.current.get(path);\n if (node && observer) {\n observer.unobserve(node);\n }\n }\n }\n if (changed) {\n renderedFilePathsRef.current = next;\n }\n return changed ? next : prev;\n });\n },\n [diffData],\n );\n\n const scrollFileIntoDiffContainer = useCallback(\n (filePath: string) => {\n ensureFilesRenderedUpTo(filePath);\n\n const targetIndex = diffData?.files.findIndex((file) => file.path === filePath) ?? -1;\n const requiredSectionIds =\n diffData && targetIndex >= 0\n ? diffData.files.slice(0, targetIndex + 1).map((file) => getFileElementId(file.path))\n : [getFileElementId(filePath)];\n const requestId = scrollRequestIdRef.current + 1;\n scrollRequestIdRef.current = requestId;\n\n const reduceMotion = false;\n const behavior: ScrollBehavior = reduceMotion ? 'instant' : 'smooth';\n\n const areRequiredSectionsReady = () => {\n for (const sectionId of requiredSectionIds) {\n const sectionNode = document.getElementById(sectionId);\n if (!sectionNode || sectionNode.dataset.rendered !== 'true') {\n return false;\n }\n }\n return true;\n };\n\n const tryScroll = (behavior: ScrollBehavior) => {\n const scrollContainer = diffScrollContainerRef.current;\n const target = document.getElementById(getFileElementId(filePath));\n if (!scrollContainer || !target) {\n return false;\n }\n\n const containerRect = scrollContainer.getBoundingClientRect();\n const targetRect = target.getBoundingClientRect();\n const targetScrollTop = scrollContainer.scrollTop + (targetRect.top - containerRect.top);\n\n scrollContainer.scrollTo({\n top: Math.max(0, targetScrollTop),\n behavior,\n });\n return true;\n };\n\n let attempts = 0;\n const attemptScroll = () => {\n requestAnimationFrame(() => {\n if (scrollRequestIdRef.current !== requestId) {\n return;\n }\n\n if (!areRequiredSectionsReady()) {\n if (attempts < SIDEBAR_SCROLL_MAX_ATTEMPTS) {\n attempts++;\n attemptScroll();\n }\n return;\n }\n\n if (!tryScroll(behavior)) {\n if (attempts < SIDEBAR_SCROLL_MAX_ATTEMPTS) {\n attempts++;\n attemptScroll();\n }\n return;\n }\n\n window.setTimeout(() => {\n if (scrollRequestIdRef.current !== requestId) {\n return;\n }\n // Re-run smooth scroll after layout settles to absorb lazy-render shifts.\n tryScroll(behavior);\n }, SIDEBAR_SCROLL_CORRECTION_DELAY_MS);\n });\n };\n attemptScroll();\n },\n [diffData, diffScrollContainerRef, ensureFilesRenderedUpTo],\n );\n\n useEffect(() => {\n if (!diffData || diffData.targetCommitish === 'stdin') return;\n\n const ref = diffData.targetCommitish || 'HEAD';\n const generatedStatusRevisionKey = `${diffData.requestedBaseCommitish ?? ''}...${diffData.requestedTargetCommitish ?? ''}:${diffData.requestedBaseMode ?? 'direct'}`;\n\n diffData.files.forEach((file) => {\n if (\n !renderedFilePaths.has(file.path) ||\n file.isGenerated !== false ||\n file.status === 'deleted'\n ) {\n return;\n }\n\n const cacheKey = `${generatedStatusRevisionKey}:${ref}:${file.path}`;\n if (generatedStatusCheckedRef.current.has(cacheKey)) {\n return;\n }\n generatedStatusCheckedRef.current.add(cacheKey);\n\n const encodedPath = encodeURIComponent(file.path);\n fetch(`/api/generated-status/${encodedPath}?ref=${encodeURIComponent(ref)}`)\n .then((res) => (res.ok ? res.json() : null))\n .then((payload: { isGenerated?: unknown } | null) => {\n if (!payload || payload.isGenerated !== true) return;\n\n setDiffData((prev) => {\n if (!prev) return prev;\n if (\n prev.requestedBaseCommitish !== diffData.requestedBaseCommitish ||\n prev.requestedTargetCommitish !== diffData.requestedTargetCommitish ||\n prev.requestedBaseMode !== diffData.requestedBaseMode\n ) {\n return prev;\n }\n\n let changed = false;\n const nextFiles = prev.files.map((candidate) => {\n if (candidate.path !== file.path || candidate.isGenerated) {\n return candidate;\n }\n changed = true;\n return { ...candidate, isGenerated: true };\n });\n\n return changed ? { ...prev, files: nextFiles } : prev;\n });\n })\n .catch(() => {\n // Ignore generated status fetch failures and keep fast path.\n });\n });\n }, [diffData, renderedFilePaths, setDiffData]);\n\n return {\n renderedFilePaths,\n ensureFileRendered,\n ensureFilesRenderedUpTo,\n registerLazyFileContainer,\n scrollFileIntoDiffContainer,\n };\n}\n","a72112f:src/client/hooks/useLazyDiffRendering.ts":"import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { type DiffResponse } from '../../types/diff';\nimport { getFileElementId } from '../utils/domUtils';\n\nconst INITIAL_RENDERED_FILE_COUNT = 8;\nconst LAZY_RENDER_ROOT_MARGIN = '1200px 0px';\nconst SIDEBAR_SCROLL_MAX_ATTEMPTS = 60;\nconst SIDEBAR_SCROLL_CORRECTION_DELAY_MS = 180;\n\ninterface UseLazyDiffRenderingOptions {\n diffData: DiffResponse | null;\n diffScrollContainerRef: React.RefObject<HTMLElement | null>;\n setDiffData: React.Dispatch<React.SetStateAction<DiffResponse | null>>;\n scrollBehavior: ScrollBehavior;\n}\n\ninterface UseLazyDiffRenderingReturn {\n renderedFilePaths: Set<string>;\n ensureFileRendered: (filePath: string) => void;\n ensureFilesRenderedUpTo: (filePath: string) => void;\n registerLazyFileContainer: (filePath: string, node: HTMLDivElement | null) => void;\n scrollFileIntoDiffContainer: (filePath: string) => void;\n}\n\nexport function useLazyDiffRendering({\n diffData,\n diffScrollContainerRef,\n setDiffData,\n scrollBehavior,\n}: UseLazyDiffRenderingOptions): UseLazyDiffRenderingReturn {\n const [renderedFilePaths, setRenderedFilePaths] = useState<Set<string>>(new Set());\n const renderedFilePathsRef = useRef<Set<string>>(new Set());\n const lazyFileObserverRef = useRef<IntersectionObserver | null>(null);\n const lazyFileNodesRef = useRef<Map<string, HTMLDivElement>>(new Map());\n const generatedStatusCheckedRef = useRef<Set<string>>(new Set());\n const renderedRevisionKeyRef = useRef<string | null>(null);\n const scrollRequestIdRef = useRef(0);\n\n useEffect(() => {\n if (!diffData) {\n const nextPaths = new Set<string>();\n renderedFilePathsRef.current = nextPaths;\n setRenderedFilePaths(nextPaths);\n generatedStatusCheckedRef.current.clear();\n renderedRevisionKeyRef.current = null;\n return;\n }\n\n const revisionKey = `${diffData.requestedBaseCommitish ?? ''}:${diffData.requestedTargetCommitish ?? ''}:${diffData.requestedBaseMode ?? 'direct'}`;\n if (renderedRevisionKeyRef.current === revisionKey) {\n return;\n }\n renderedRevisionKeyRef.current = revisionKey;\n\n const initialPaths = diffData.files\n .slice(0, INITIAL_RENDERED_FILE_COUNT)\n .map((file) => file.path);\n const nextPaths = new Set(initialPaths);\n renderedFilePathsRef.current = nextPaths;\n setRenderedFilePaths(nextPaths);\n generatedStatusCheckedRef.current.clear();\n }, [diffData]);\n\n const ensureFileRendered = useCallback((filePath: string) => {\n const node = lazyFileNodesRef.current.get(filePath);\n if (node && lazyFileObserverRef.current) {\n lazyFileObserverRef.current.unobserve(node);\n }\n\n setRenderedFilePaths((prev) => {\n if (prev.has(filePath)) return prev;\n const next = new Set(prev);\n next.add(filePath);\n renderedFilePathsRef.current = next;\n return next;\n });\n }, []);\n\n const registerLazyFileContainer = useCallback((filePath: string, node: HTMLDivElement | null) => {\n const observer = lazyFileObserverRef.current;\n const previousNode = lazyFileNodesRef.current.get(filePath);\n if (previousNode && observer) {\n observer.unobserve(previousNode);\n }\n\n if (!node) {\n lazyFileNodesRef.current.delete(filePath);\n return;\n }\n\n lazyFileNodesRef.current.set(filePath, node);\n if (renderedFilePathsRef.current.has(filePath) || !observer) {\n return;\n }\n observer.observe(node);\n }, []);\n\n useEffect(() => {\n if (lazyFileObserverRef.current) {\n lazyFileObserverRef.current.disconnect();\n }\n\n if (!diffData) {\n lazyFileObserverRef.current = null;\n return;\n }\n\n const observer = new IntersectionObserver(\n (entries) => {\n setRenderedFilePaths((prev) => {\n let changed = false;\n const next = new Set(prev);\n\n entries.forEach((entry) => {\n if (!entry.isIntersecting) return;\n const path = (entry.target as HTMLElement).dataset.filePath;\n if (!path || next.has(path)) return;\n next.add(path);\n observer.unobserve(entry.target);\n changed = true;\n });\n\n if (changed) {\n renderedFilePathsRef.current = next;\n }\n return changed ? next : prev;\n });\n },\n {\n root: diffScrollContainerRef.current,\n rootMargin: LAZY_RENDER_ROOT_MARGIN,\n },\n );\n\n lazyFileObserverRef.current = observer;\n lazyFileNodesRef.current.forEach((node, filePath) => {\n if (!renderedFilePathsRef.current.has(filePath)) {\n observer.observe(node);\n }\n });\n\n return () => {\n observer.disconnect();\n lazyFileObserverRef.current = null;\n };\n }, [diffData, diffScrollContainerRef]);\n\n const ensureFilesRenderedUpTo = useCallback(\n (filePath: string) => {\n if (!diffData) return;\n const targetIndex = diffData.files.findIndex((file) => file.path === filePath);\n if (targetIndex < 0) return;\n\n const observer = lazyFileObserverRef.current;\n\n setRenderedFilePaths((prev) => {\n let changed = false;\n const next = new Set(prev);\n for (let i = 0; i <= targetIndex; i++) {\n const path = diffData.files[i]?.path;\n if (path && !next.has(path)) {\n next.add(path);\n changed = true;\n const node = lazyFileNodesRef.current.get(path);\n if (node && observer) {\n observer.unobserve(node);\n }\n }\n }\n if (changed) {\n renderedFilePathsRef.current = next;\n }\n return changed ? next : prev;\n });\n },\n [diffData],\n );\n\n const scrollFileIntoDiffContainer = useCallback(\n (filePath: string) => {\n ensureFilesRenderedUpTo(filePath);\n\n const targetIndex = diffData?.files.findIndex((file) => file.path === filePath) ?? -1;\n const requiredSectionIds =\n diffData && targetIndex >= 0\n ? diffData.files.slice(0, targetIndex + 1).map((file) => getFileElementId(file.path))\n : [getFileElementId(filePath)];\n const requestId = scrollRequestIdRef.current + 1;\n scrollRequestIdRef.current = requestId;\n\n const areRequiredSectionsReady = () => {\n for (const sectionId of requiredSectionIds) {\n const sectionNode = document.getElementById(sectionId);\n if (!sectionNode || sectionNode.dataset.rendered !== 'true') {\n return false;\n }\n }\n return true;\n };\n\n const tryScroll = (behavior: ScrollBehavior) => {\n const scrollContainer = diffScrollContainerRef.current;\n const target = document.getElementById(getFileElementId(filePath));\n if (!scrollContainer || !target) {\n return false;\n }\n\n const containerRect = scrollContainer.getBoundingClientRect();\n const targetRect = target.getBoundingClientRect();\n const targetScrollTop = scrollContainer.scrollTop + (targetRect.top - containerRect.top);\n\n scrollContainer.scrollTo({\n top: Math.max(0, targetScrollTop),\n behavior,\n });\n return true;\n };\n\n let attempts = 0;\n const attemptScroll = () => {\n requestAnimationFrame(() => {\n if (scrollRequestIdRef.current !== requestId) {\n return;\n }\n\n if (!areRequiredSectionsReady()) {\n if (attempts < SIDEBAR_SCROLL_MAX_ATTEMPTS) {\n attempts++;\n attemptScroll();\n }\n return;\n }\n\n if (!tryScroll(scrollBehavior)) {\n if (attempts < SIDEBAR_SCROLL_MAX_ATTEMPTS) {\n attempts++;\n attemptScroll();\n }\n return;\n }\n\n window.setTimeout(() => {\n if (scrollRequestIdRef.current !== requestId) {\n return;\n }\n // Re-run smooth scroll after layout settles to absorb lazy-render shifts.\n tryScroll(scrollBehavior);\n }, SIDEBAR_SCROLL_CORRECTION_DELAY_MS);\n });\n };\n attemptScroll();\n },\n [diffData, diffScrollContainerRef, ensureFilesRenderedUpTo, scrollBehavior],\n );\n\n useEffect(() => {\n if (!diffData || diffData.targetCommitish === 'stdin') return;\n\n const ref = diffData.targetCommitish || 'HEAD';\n const generatedStatusRevisionKey = `${diffData.requestedBaseCommitish ?? ''}...${diffData.requestedTargetCommitish ?? ''}:${diffData.requestedBaseMode ?? 'direct'}`;\n\n diffData.files.forEach((file) => {\n if (\n !renderedFilePaths.has(file.path) ||\n file.isGenerated !== false ||\n file.status === 'deleted'\n ) {\n return;\n }\n\n const cacheKey = `${generatedStatusRevisionKey}:${ref}:${file.path}`;\n if (generatedStatusCheckedRef.current.has(cacheKey)) {\n return;\n }\n generatedStatusCheckedRef.current.add(cacheKey);\n\n const encodedPath = encodeURIComponent(file.path);\n fetch(`/api/generated-status/${encodedPath}?ref=${encodeURIComponent(ref)}`)\n .then((res) => (res.ok ? res.json() : null))\n .then((payload: { isGenerated?: unknown } | null) => {\n if (!payload || payload.isGenerated !== true) return;\n\n setDiffData((prev) => {\n if (!prev) return prev;\n if (\n prev.requestedBaseCommitish !== diffData.requestedBaseCommitish ||\n prev.requestedTargetCommitish !== diffData.requestedTargetCommitish ||\n prev.requestedBaseMode !== diffData.requestedBaseMode\n ) {\n return prev;\n }\n\n let changed = false;\n const nextFiles = prev.files.map((candidate) => {\n if (candidate.path !== file.path || candidate.isGenerated) {\n return candidate;\n }\n changed = true;\n return { ...candidate, isGenerated: true };\n });\n\n return changed ? { ...prev, files: nextFiles } : prev;\n });\n })\n .catch(() => {\n // Ignore generated status fetch failures and keep fast path.\n });\n });\n }, [diffData, renderedFilePaths, setDiffData]);\n\n return {\n renderedFilePaths,\n ensureFileRendered,\n ensureFilesRenderedUpTo,\n registerLazyFileContainer,\n scrollFileIntoDiffContainer,\n };\n}\n","a72112f:src/client/hooks/usePreferredScrollBehavior.test.ts":"import { act, renderHook, waitFor } from '@testing-library/react';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { usePreferredScrollBehavior } from './usePreferredScrollBehavior';\n\nconst setMatchMedia = (initialMatches: boolean) => {\n let matches = initialMatches;\n const listeners = new Set<(event: MediaQueryListEvent) => void>();\n\n const mediaQueryList = {\n get matches() {\n return matches;\n },\n media: '(prefers-reduced-motion: reduce)',\n onchange: null,\n addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.add(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n removeEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => {\n if (type === 'change' && typeof listener === 'function') {\n listeners.delete(listener as (event: MediaQueryListEvent) => void);\n }\n }),\n addListener: vi.fn(),\n removeListener: vi.fn(),\n dispatchEvent: vi.fn(),\n } as MediaQueryList;\n\n Object.defineProperty(window, 'matchMedia', {\n configurable: true,\n writable: true,\n value: vi.fn(() => mediaQueryList),\n });\n\n return {\n setMatches(nextMatches: boolean) {\n matches = nextMatches;\n const event = { matches: nextMatches, media: mediaQueryList.media } as MediaQueryListEvent;\n listeners.forEach((listener) => listener(event));\n },\n };\n};\n\ndescribe('usePreferredScrollBehavior', () => {\n beforeEach(() => {\n setMatchMedia(false);\n });\n\n it(\"returns 'smooth' for setting 'enabled' even when OS prefers reduced motion\", () => {\n setMatchMedia(true);\n const { result } = renderHook(() => usePreferredScrollBehavior('enabled'));\n expect(result.current).toBe('smooth');\n });\n\n it(\"returns 'instant' for setting 'disabled' even when OS does not prefer reduced motion\", () => {\n setMatchMedia(false);\n const { result } = renderHook(() => usePreferredScrollBehavior('disabled'));\n expect(result.current).toBe('instant');\n });\n\n it(\"resolves 'auto' to 'smooth' when OS does not prefer reduced motion\", () => {\n setMatchMedia(false);\n const { result } = renderHook(() => usePreferredScrollBehavior('auto'));\n expect(result.current).toBe('smooth');\n });\n\n it(\"resolves 'auto' to 'instant' when OS prefers reduced motion\", () => {\n setMatchMedia(true);\n const { result } = renderHook(() => usePreferredScrollBehavior('auto'));\n expect(result.current).toBe('instant');\n });\n\n it(\"re-resolves 'auto' when OS preference toggles at runtime\", async () => {\n const matchMedia = setMatchMedia(false);\n const { result } = renderHook(() => usePreferredScrollBehavior('auto'));\n expect(result.current).toBe('smooth');\n\n act(() => {\n matchMedia.setMatches(true);\n });\n\n await waitFor(() => {\n expect(result.current).toBe('instant');\n });\n\n act(() => {\n matchMedia.setMatches(false);\n });\n\n await waitFor(() => {\n expect(result.current).toBe('smooth');\n });\n });\n});\n","a72112f:src/client/hooks/usePreferredScrollBehavior.ts":"import { useSyncExternalStore } from 'react';\n\nexport type ScrollAnimationSetting = 'auto' | 'enabled' | 'disabled';\n\nconst REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';\n\nfunction subscribeReducedMotion(callback: () => void) {\n const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);\n mediaQuery.addEventListener('change', callback);\n return () => mediaQuery.removeEventListener('change', callback);\n}\n\nfunction getReducedMotionSnapshot(): boolean {\n return window.matchMedia(REDUCED_MOTION_QUERY).matches;\n}\n\nfunction getReducedMotionServerSnapshot(): boolean {\n return false;\n}\n\nexport function usePreferredScrollBehavior(setting: ScrollAnimationSetting): ScrollBehavior {\n const systemPrefersReducedMotion = useSyncExternalStore(\n subscribeReducedMotion,\n getReducedMotionSnapshot,\n getReducedMotionServerSnapshot,\n );\n\n if (setting === 'enabled') return 'smooth';\n if (setting === 'disabled') return 'instant';\n return systemPrefersReducedMotion ? 'instant' : 'smooth';\n}\n"},"blobUrls":{},"comments":[{"id":"site-demo-scroll-behavior-thread","filePath":"src/client/hooks/usePreferredScrollBehavior.ts","createdAt":"2026-05-05T00:00:00.000Z","updatedAt":"2026-05-05T00:00:00.000Z","position":{"side":"new","line":21},"codeSnapshot":{"content":" const systemPrefersReducedMotion = useSyncExternalStore(","language":"typescript"},"messages":[{"id":"site-demo-scroll-behavior-message","body":"Good place to centralize the reduced-motion preference before it reaches the diff scroller.","author":"demo-reviewer","createdAt":"2026-05-05T00:00:00.000Z","updatedAt":"2026-05-05T00:00:00.000Z"}]},{"id":"site-demo-app-thread","filePath":"src/client/App.tsx","createdAt":"2026-05-05T00:01:00.000Z","updatedAt":"2026-05-05T00:01:00.000Z","position":{"side":"new","line":153},"codeSnapshot":{"content":" const { settings, updateSettings, scrollBehavior } = useAppearanceSettings();","language":"tsx"},"messages":[{"id":"site-demo-app-message","body":"The derived scroll behavior is now wired into lazy rendering, so navigating large diffs can respect the user setting.","author":"demo-reviewer","createdAt":"2026-05-05T00:01:00.000Z","updatedAt":"2026-05-05T00:01:00.000Z"}]}]}
|