Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion src/browser/components/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useParams, useNavigate } from "react-router-dom";
import { GithubIcon } from "../ui/github-icon";
import {
Expand Down Expand Up @@ -34,6 +35,69 @@ import {
} from "../ui/hover-card";
import { version } from "../../../package.json";

// ============================================================================
// Home Page Filter Matching (for triggering a refresh when a notification fires)
// ============================================================================

const ALL_REPOS_KEY = "__all_repos__";
const ORG_FILTER_PREFIX = "__org__:";
const FILTER_GROUPS_KEY = "pulldash_filter_groups";
const STATE_FILTER_KEY = "pulldash_filter_state";

interface RepoFilter {
name: string;
mode: string;
authoredBy?: string;
enabled?: boolean;
}

interface FilterGroup {
id: string;
name: string;
repos: RepoFilter[];
}

interface FilterGroupsStorage {
groups: FilterGroup[];
selectedGroupId: string;
}

function isRepoInHomeFilters(
owner: string,
repo: string,
prState?: string
): boolean {
try {
const stored = localStorage.getItem(FILTER_GROUPS_KEY);
if (!stored) return false;
const parsed = JSON.parse(stored) as FilterGroupsStorage;
const group = parsed.groups.find((g) => g.id === parsed.selectedGroupId);
if (!group) return false;

const enabledRepos = group.repos.filter((r) => r.enabled !== false);
if (enabledRepos.length === 0) return false;

// Check state filter — only when we know the PR's state
if (prState) {
const stateFilter = localStorage.getItem(STATE_FILTER_KEY) ?? "open";
if (stateFilter === "open" && prState !== "open") return false;
if (stateFilter === "closed" && prState !== "closed") return false;
}

const fullName = `${owner}/${repo}`;

for (const filter of enabledRepos) {
if (filter.name === ALL_REPOS_KEY) return true;
if (filter.name.startsWith(ORG_FILTER_PREFIX)) return true;
if (filter.name === fullName) return true;
}

return false;
} catch {
return false;
}
}

// ============================================================================
// App Shell - Tab-based Layout
// ============================================================================
Expand All @@ -59,6 +123,7 @@ export function AppShell() {
tab?: string;
}>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const githubStore = useGitHubStore();

// URL is the source of truth - sync URL → Tab
Expand Down Expand Up @@ -244,6 +309,9 @@ export function AppShell() {
`https://avatars.githubusercontent.com/${tab.owner}`
);
setNotifiedAt(prId, enrichment.updatedAt);
if (isRepoInHomeFilters(tab.owner, tab.repo)) {
queryClient.invalidateQueries({ queryKey: ["pr-list"] });
}
}
}
}
Expand Down Expand Up @@ -277,6 +345,9 @@ export function AppShell() {
`https://avatars.githubusercontent.com/${owner}`
);
setNotifiedAt(prId, enrichment.updatedAt);
if (isRepoInHomeFilters(owner, repo, pr.state)) {
queryClient.invalidateQueries({ queryKey: ["pr-list"] });
}
}
}
} catch {
Expand All @@ -293,7 +364,14 @@ export function AppShell() {
return () => {
if (interval) clearInterval(interval);
};
}, [tabs, activeTab, githubStore, markTabUpdated, clearTabUpdated]);
}, [
tabs,
activeTab,
githubStore,
markTabUpdated,
clearTabUpdated,
queryClient,
]);

// Clear tabs when user logs out
useEffect(() => {
Expand Down
38 changes: 36 additions & 2 deletions src/browser/contexts/pr-review/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ export class PRReviewStore {
}
} catch {}

// Load review session from localStorage (pending review node ID + SHA)
try {
const stored = localStorage.getItem(`${this.storageKey}-review-session`);
if (stored) {
const session = JSON.parse(stored);
this.pendingReviewNodeId = session.pendingReviewNodeId ?? null;
this.reviewSha = session.reviewSha ?? null;
}
} catch {}

// Sort files to match file tree order (folders first, then alphabetically)
const sortedFiles = sortFilesLikeTree(initialState.files);
this.baseFiles = sortedFiles;
Expand Down Expand Up @@ -1134,6 +1144,7 @@ export class PRReviewStore {
try {
localStorage.removeItem(`${this.storageKey}-pending`);
localStorage.removeItem(`${this.storageKey}-body`);
localStorage.removeItem(`${this.storageKey}-review-session`);
} catch {}
}

Expand Down Expand Up @@ -3028,20 +3039,41 @@ export class PRReviewStore {
? { ...c, nodeId: commentNodeId, databaseId: commentDatabaseId }
: c
);
// Also store the review node ID
// Also store the review node ID and persist session
this.pendingReviewNodeId = reviewNodeId;
this.persistReviewSession();
this.persistPendingComments(pendingComments);
this.set({ pendingComments });
};

// Store the pending review node ID for submission
// Store the pending review node ID and SHA for submission
private pendingReviewNodeId: string | null = null;
private reviewSha: string | null = null;

getPendingReviewNodeId = () => this.pendingReviewNodeId;
setPendingReviewNodeId = (id: string | null) => {
this.pendingReviewNodeId = id;
this.persistReviewSession();
};

getReviewSha = () => this.reviewSha;
setReviewSha = (sha: string | null) => {
this.reviewSha = sha;
this.persistReviewSession();
};

private persistReviewSession() {
try {
localStorage.setItem(
`${this.storageKey}-review-session`,
JSON.stringify({
pendingReviewNodeId: this.pendingReviewNodeId,
reviewSha: this.reviewSha,
})
);
} catch {}
}

updateComment = (commentId: number, updatedComment: ReviewComment) => {
this.set({
comments: this.state.comments.map((c) =>
Expand Down Expand Up @@ -3104,6 +3136,8 @@ export class PRReviewStore {

clearReviewState = () => {
this.clearPendingState();
this.pendingReviewNodeId = null;
this.reviewSha = null;
this.set({
pendingComments: [],
pendingReviewId: null,
Expand Down
2 changes: 2 additions & 0 deletions src/browser/contexts/pr-review/useCommentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function useCommentActions() {
result.commentId,
result.commentDatabaseId
);
// Store the commit SHA so the REST fallback uses the correct version
store.setReviewSha(pr.head.sha);
} catch (error) {
console.error("Failed to sync pending comment to GitHub:", error);
}
Expand Down
80 changes: 49 additions & 31 deletions src/browser/contexts/pr-review/useReviewActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,39 +25,57 @@ export function useReviewActions() {
try {
// Get the pending review node ID (from GraphQL)
const reviewNodeId = store.getPendingReviewNodeId();
const reviewSha = store.getReviewSha() ?? pr.head.sha;

// Track whether GraphQL submission succeeded
let submittedViaGraphQL = false;

if (reviewNodeId) {
// Submit via GraphQL - we'll find the review ID after refreshing
await github.submitPendingReview(reviewNodeId, event, state.reviewBody);
} else if (state.pendingComments.length > 0) {
// Fallback: create a new review with all comments via REST
// Redirect :commit metadata comments to the first real file
const firstFile = state.files[0]?.filename;
newReview = await github.createPRReview(owner, repo, pr.number, {
commit_id: pr.head.sha,
event,
body: state.reviewBody,
comments: state.pendingComments.map(
({ path, line, body, side, start_line }) => {
const isMetadata = path === ":commit" && firstFile;
return {
path: isMetadata ? firstFile : path,
line: isMetadata ? 1 : line,
body,
side: side as "LEFT" | "RIGHT",
start_line: isMetadata ? undefined : start_line,
};
}
),
});
} else {
// Just submitting a review with no comments (APPROVE, etc)
newReview = await github.createPRReview(owner, repo, pr.number, {
commit_id: pr.head.sha,
event,
body: state.reviewBody,
comments: [],
});
try {
// Submit via GraphQL - we'll find the review ID after refreshing
await github.submitPendingReview(
reviewNodeId,
event,
state.reviewBody
);
submittedViaGraphQL = true;
} catch {
// GraphQL failed (e.g. pending review was already submitted).
// Fall through to REST fallback below.
}
}

if (!submittedViaGraphQL) {
if (state.pendingComments.length > 0) {
// REST fallback: create a new review with all comments
// Redirect :commit metadata comments to the first real file
const firstFile = state.files[0]?.filename;
newReview = await github.createPRReview(owner, repo, pr.number, {
commit_id: reviewSha,
event,
body: state.reviewBody,
comments: state.pendingComments.map(
({ path, line, body, side, start_line }) => {
const isMetadata = path === ":commit" && firstFile;
return {
path: isMetadata ? firstFile : path,
line: isMetadata ? 1 : line,
body,
side: side as "LEFT" | "RIGHT",
start_line: isMetadata ? undefined : start_line,
};
}
),
});
} else {
// Just submitting a review with no comments (APPROVE, etc)
newReview = await github.createPRReview(owner, repo, pr.number, {
commit_id: reviewSha,
event,
body: state.reviewBody,
comments: [],
});
}
}

github.invalidatePR(owner, repo, pr.number);
Expand Down
Loading