Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
34c6dfa
🤖 Implement read-only hunk tracking with content-based IDs and LRU ev…
ammar-agent Oct 19, 2025
412ceb0
🤖 Add scroll-into-view for j/k hunk navigation
ammar-agent Oct 19, 2025
d86248c
🤖 Add mark-all-as-read button for files in FileTree
ammar-agent Oct 19, 2025
51b9a5b
🤖 Fix: Respect manual expand/collapse after marking as read
ammar-agent Oct 19, 2025
f9102ea
🤖 Simplify: Always collapse/expand on read state change
ammar-agent Oct 19, 2025
4d3fb04
🤖 Improve visual distinction: Green header for read hunks
ammar-agent Oct 19, 2025
309b629
🤖 Use plan blue for read state indicators
ammar-agent Oct 19, 2025
39a3346
🤖 Add plan blue border and improved collapsed text for read hunks
ammar-agent Oct 19, 2025
6a813d1
🤖 Change selected hunk border to amber to avoid clash with read state
ammar-agent Oct 19, 2025
1f17bfa
🤖 Centralize read color and use strikethrough for fully-read files
ammar-agent Oct 19, 2025
a438ce2
🤖 Dim filenames with strikethrough when fully read in FileTree
ammar-agent Oct 19, 2025
70e6b53
🤖 Centralize review accent color and remove hover border clash
ammar-agent Oct 19, 2025
57bdb15
🤖 Dim files with unknown read state in FileTree
ammar-agent Oct 19, 2025
ce3d4f4
🤖 Fix FileTree dimming for unknown read state
ammar-agent Oct 19, 2025
8c071b4
🤖 Reduce intensity of review accent color
ammar-agent Oct 19, 2025
5073811
🤖 Use alpha 0.75 and remove alpha variant anti-pattern
ammar-agent Oct 19, 2025
b6256dc
🤖 Add keybind hints to review note placeholder
ammar-agent Oct 19, 2025
1ed2566
🤖 Lowercase keybinds in review note placeholder
ammar-agent Oct 19, 2025
145a87a
🤖 Make hunk active when clicking line to start comment
ammar-agent Oct 19, 2025
0a0699a
🤖 Remove duplicated evictOldestReviews logic from test
ammar-agent Oct 19, 2025
8cc4b7e
🤖 Auto-navigate to next hunk when marking as read with filter off
ammar-agent Oct 19, 2025
d06c959
🤖 Simplify toggle read navigation logic
ammar-agent Oct 19, 2025
4f293b3
🤖 Add eslint-disable comment for intentional dependency omission
ammar-agent Oct 19, 2025
7879f77
🤖 Include line ranges in hunk ID hashing to avoid collisions
ammar-agent Oct 19, 2025
a7a7861
🤖 Propagate read status up through file tree directories
ammar-agent Oct 19, 2025
eaae538
🤖 Remove unused hasPartiallyRead variable
ammar-agent Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions src/components/RightSidebar/CodeReview/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,42 @@ const TreeNode = styled.div<{ depth: number; isSelected: boolean }>`
}
`;

const FileName = styled.span`
const FileName = styled.span<{ isFullyRead?: boolean; isUnknownState?: boolean }>`
color: #ccc;
flex: 1;
${(props) =>
props.isFullyRead &&
`
color: #666;
text-decoration: line-through;
text-decoration-color: var(--color-read);
text-decoration-thickness: 2px;
`}
${(props) =>
props.isUnknownState &&
!props.isFullyRead &&
`
color: #666;
`}
`;

const DirectoryName = styled.span`
const DirectoryName = styled.span<{ isFullyRead?: boolean; isUnknownState?: boolean }>`
color: #888;
flex: 1;
${(props) =>
props.isFullyRead &&
`
color: #666;
text-decoration: line-through;
text-decoration-color: var(--color-read);
text-decoration-thickness: 2px;
`}
${(props) =>
props.isUnknownState &&
!props.isFullyRead &&
`
color: #666;
`}
`;

const DirectoryStats = styled.span<{ isOpen: boolean }>`
Expand Down Expand Up @@ -117,13 +145,56 @@ const EmptyState = styled.div`
text-align: center;
`;

/**
* Compute read status for a directory by recursively checking all descendant files
* Returns "fully-read" if all files are fully read, "unknown" if any file has unknown status, null otherwise
*/
function computeDirectoryReadStatus(
node: FileTreeNode,
getFileReadStatus?: (filePath: string) => { total: number; read: number } | null
): "fully-read" | "unknown" | null {
if (!node.isDirectory || !getFileReadStatus) return null;

let hasUnknown = false;
let fileCount = 0;
let fullyReadCount = 0;

const checkNode = (n: FileTreeNode) => {
if (n.isDirectory) {
// Recurse into children
n.children.forEach(checkNode);
} else {
// Check file status
fileCount++;
const status = getFileReadStatus(n.path);
if (status === null) {
hasUnknown = true;
} else if (status.read === status.total && status.total > 0) {
fullyReadCount++;
}
}
};

checkNode(node);

// If any file has unknown state, directory is unknown
if (hasUnknown) return "unknown";

// If all files are fully read, directory is fully read
if (fileCount > 0 && fullyReadCount === fileCount) return "fully-read";

// Otherwise, directory has partial/no read state
return null;
}

const TreeNodeContent: React.FC<{
node: FileTreeNode;
depth: number;
selectedPath: string | null;
onSelectFile: (path: string | null) => void;
commonPrefix: string | null;
}> = ({ node, depth, selectedPath, onSelectFile, commonPrefix }) => {
getFileReadStatus?: (filePath: string) => { total: number; read: number } | null;
}> = ({ node, depth, selectedPath, onSelectFile, commonPrefix, getFileReadStatus }) => {
const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels

const handleClick = (e: React.MouseEvent) => {
Expand Down Expand Up @@ -154,6 +225,20 @@ const TreeNodeContent: React.FC<{

const isSelected = selectedPath === node.path;

// Compute read status for files and directories
let isFullyRead = false;
let isUnknownState = false;

if (node.isDirectory) {
const dirStatus = computeDirectoryReadStatus(node, getFileReadStatus);
isFullyRead = dirStatus === "fully-read";
isUnknownState = dirStatus === "unknown";
} else if (getFileReadStatus) {
const readStatus = getFileReadStatus(node.path);
isFullyRead = readStatus ? readStatus.read === readStatus.total && readStatus.total > 0 : false;
isUnknownState = readStatus === null;
}

return (
<>
<TreeNode depth={depth} isSelected={isSelected} onClick={handleClick}>
Expand All @@ -162,7 +247,9 @@ const TreeNodeContent: React.FC<{
<ToggleIcon isOpen={isOpen} data-toggle onClick={handleToggleClick}>
â–¶
</ToggleIcon>
<DirectoryName>{node.name || "/"}</DirectoryName>
<DirectoryName isFullyRead={isFullyRead} isUnknownState={isUnknownState}>
{node.name || "/"}
</DirectoryName>
{node.totalStats &&
(node.totalStats.additions > 0 || node.totalStats.deletions > 0) && (
<DirectoryStats isOpen={isOpen}>
Expand All @@ -184,7 +271,9 @@ const TreeNodeContent: React.FC<{
) : (
<>
<span style={{ width: "12px" }} />
<FileName>{node.name}</FileName>
<FileName isFullyRead={isFullyRead} isUnknownState={isUnknownState}>
{node.name}
</FileName>
{node.stats && (
<Stats>
{node.stats.additions > 0 && <Additions>+{node.stats.additions}</Additions>}
Expand All @@ -205,6 +294,7 @@ const TreeNodeContent: React.FC<{
selectedPath={selectedPath}
onSelectFile={onSelectFile}
commonPrefix={commonPrefix}
getFileReadStatus={getFileReadStatus}
/>
))}
</>
Expand All @@ -217,6 +307,7 @@ interface FileTreeExternalProps {
onSelectFile: (path: string | null) => void;
isLoading?: boolean;
commonPrefix?: string | null;
getFileReadStatus?: (filePath: string) => { total: number; read: number } | null;
}

export const FileTree: React.FC<FileTreeExternalProps> = ({
Expand All @@ -225,6 +316,7 @@ export const FileTree: React.FC<FileTreeExternalProps> = ({
onSelectFile,
isLoading = false,
commonPrefix = null,
getFileReadStatus,
}) => {
// Find the node at the common prefix path to start rendering from
const startNode = React.useMemo(() => {
Expand Down Expand Up @@ -262,6 +354,7 @@ export const FileTree: React.FC<FileTreeExternalProps> = ({
selectedPath={selectedPath}
onSelectFile={onSelectFile}
commonPrefix={commonPrefix}
getFileReadStatus={getFileReadStatus}
/>
))
) : (
Expand Down
78 changes: 69 additions & 9 deletions src/components/RightSidebar/CodeReview/HunkViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { SelectableDiffRenderer } from "../../shared/DiffRenderer";
interface HunkViewerProps {
hunk: DiffHunk;
isSelected?: boolean;
isRead?: boolean;
onClick?: () => void;
onToggleRead?: () => void;
onReviewNote?: (note: string) => void;
}

const HunkContainer = styled.div<{ isSelected: boolean }>`
const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>`
background: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 4px;
Expand All @@ -24,18 +26,21 @@ const HunkContainer = styled.div<{ isSelected: boolean }>`
transition: all 0.2s ease;

${(props) =>
props.isSelected &&
props.isRead &&
`
border-color: #007acc;
box-shadow: 0 0 0 1px #007acc;
border-color: var(--color-read);
`}

&:hover {
border-color: #007acc;
}
${(props) =>
props.isSelected &&
`
border-color: var(--color-review-accent);
box-shadow: 0 0 0 1px var(--color-review-accent);
`}
`;

const HunkHeader = styled.div`
/* Keep grayscale to avoid clashing with green/red LoC indicators */
background: #252526;
padding: 8px 12px;
border-bottom: 1px solid #3e3e42;
Expand All @@ -44,6 +49,7 @@ const HunkHeader = styled.div`
align-items: center;
font-family: var(--font-monospace);
font-size: 12px;
gap: 8px;
`;

const FilePath = styled.div`
Expand Down Expand Up @@ -124,19 +130,64 @@ const RenameInfo = styled.div`
}
`;

const ReadIndicator = styled.span`
display: inline-flex;
align-items: center;
color: var(--color-read);
font-size: 14px;
margin-right: 4px;
`;

const ToggleReadButton = styled.button`
background: transparent;
border: 1px solid #3e3e42;
border-radius: 3px;
padding: 2px 6px;
color: #888;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;

&:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--color-read);
color: var(--color-read);
}

&:active {
transform: scale(0.95);
}
`;

export const HunkViewer: React.FC<HunkViewerProps> = ({
hunk,
isSelected,
isRead = false,
onClick,
onToggleRead,
onReviewNote,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
// Collapse by default if marked as read
const [isExpanded, setIsExpanded] = useState(!isRead);

// Auto-collapse when marked as read, auto-expand when unmarked
React.useEffect(() => {
setIsExpanded(!isRead);
}, [isRead]);

const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};

const handleToggleRead = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleRead?.();
};

// Parse diff lines
const diffLines = hunk.content.split("\n").filter((line) => line.length > 0);
const lineCount = diffLines.length;
Expand All @@ -153,9 +204,11 @@ export const HunkViewer: React.FC<HunkViewerProps> = ({
return (
<HunkContainer
isSelected={isSelected ?? false}
isRead={isRead}
onClick={onClick}
role="button"
tabIndex={0}
data-hunk-id={hunk.id}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
Expand All @@ -164,6 +217,7 @@ export const HunkViewer: React.FC<HunkViewerProps> = ({
}}
>
<HunkHeader>
{isRead && <ReadIndicator title="Marked as read">✓</ReadIndicator>}
<FilePath>{hunk.filePath}</FilePath>
<LineInfo>
{!isPureRename && (
Expand All @@ -175,6 +229,11 @@ export const HunkViewer: React.FC<HunkViewerProps> = ({
<LineCount>
({lineCount} {lineCount === 1 ? "line" : "lines"})
</LineCount>
{onToggleRead && (
<ToggleReadButton onClick={handleToggleRead} title="Mark as read (m)">
{isRead ? "â—‹" : "â—‰"}
</ToggleReadButton>
)}
</LineInfo>
</HunkHeader>

Expand All @@ -190,11 +249,12 @@ export const HunkViewer: React.FC<HunkViewerProps> = ({
oldStart={hunk.oldStart}
newStart={hunk.newStart}
onReviewNote={onReviewNote}
onLineClick={onClick}
/>
</HunkContent>
) : (
<CollapsedIndicator onClick={handleToggleExpand}>
Click to expand ({lineCount} lines)
{isRead && "Hunk marked as read. "}Click to expand ({lineCount} lines)
</CollapsedIndicator>
)}

Expand Down
11 changes: 10 additions & 1 deletion src/components/RightSidebar/CodeReview/ReviewControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
onFiltersChange({ ...filters, includeDirty: e.target.checked });
};

const handleShowReadToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
onFiltersChange({ ...filters, showReadHunks: e.target.checked });
};

const handleSetDefault = () => {
setDefaultBase(filters.diffBase);
};
Expand Down Expand Up @@ -206,6 +210,11 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
Dirty
</CheckboxLabel>

<CheckboxLabel>
<input type="checkbox" checked={filters.showReadHunks} onChange={handleShowReadToggle} />
Show read
</CheckboxLabel>

<UntrackedStatus
workspaceId={workspaceId}
workspacePath={workspacePath}
Expand All @@ -215,7 +224,7 @@ export const ReviewControls: React.FC<ReviewControlsProps> = ({
<Separator />

<StatBadge>
{stats.total} {stats.total === 1 ? "hunk" : "hunks"}
{stats.read} read / {stats.total} total
</StatBadge>
</ControlsContainer>
);
Expand Down
Loading