fix(ui): rendre les vues tickets et notifications live
This commit is contained in:
parent
e0b09e4c29
commit
f9b565cfda
4 changed files with 352 additions and 69 deletions
|
|
@ -4,7 +4,7 @@ import {
|
|||
requestPermission,
|
||||
sendNotification,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
listNotifications,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
markNotificationRead,
|
||||
} from "../../lib/api";
|
||||
import type { OrchaiNotification } from "../../lib/types";
|
||||
import { useLiveRefresh } from "../../lib/useLiveRefresh";
|
||||
import { buttonClass, cardClass, pillClass } from "../ui/primitives";
|
||||
|
||||
type NewNotificationEvent = {
|
||||
|
|
@ -61,7 +62,7 @@ export default function NotificationCenter() {
|
|||
"all"
|
||||
);
|
||||
|
||||
async function loadNotifications() {
|
||||
const loadNotifications = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setNotifications([]);
|
||||
return;
|
||||
|
|
@ -73,11 +74,18 @@ export default function NotificationCenter() {
|
|||
} catch {
|
||||
// Ignore load errors in layout chrome
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
}, [projectId]);
|
||||
void loadNotifications();
|
||||
}, [loadNotifications]);
|
||||
|
||||
useLiveRefresh({
|
||||
enabled: Boolean(projectId),
|
||||
projectId,
|
||||
refresh: loadNotifications,
|
||||
fallbackIntervalMs: 15_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type TicketResourceConfig,
|
||||
} from "../../lib/ticketResource";
|
||||
import type { ProcessedTicket, Worktree } from "../../lib/types";
|
||||
import { useLiveRefresh } from "../../lib/useLiveRefresh";
|
||||
import ConfirmModal from "../ui/ConfirmModal";
|
||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||
import {
|
||||
|
|
@ -76,6 +77,24 @@ export default function TicketDetail() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false);
|
||||
const latestTicketIdRef = useRef<string | undefined>(ticketId);
|
||||
const worktreeSignatureRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
latestTicketIdRef.current = ticketId;
|
||||
}, [ticketId]);
|
||||
|
||||
const resetWorktreeUi = useCallback(() => {
|
||||
setDiff(null);
|
||||
setDiffError("");
|
||||
setDiffLoading(false);
|
||||
setAvailableBranches([]);
|
||||
setBranchInputMode("select");
|
||||
setTargetBranch("");
|
||||
setBranchesError("");
|
||||
setBranchesLoading(false);
|
||||
setBranchesLoadedForWorktreeId(null);
|
||||
}, []);
|
||||
|
||||
async function loadBranchOptions(worktreeId: string) {
|
||||
setBranchesLoading(true);
|
||||
|
|
@ -103,41 +122,63 @@ export default function TicketDetail() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!ticketId) return;
|
||||
const loadData = useCallback(async () => {
|
||||
if (!ticketId) {
|
||||
setTicket(null);
|
||||
setWorktree(null);
|
||||
setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG);
|
||||
resetWorktreeUi();
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
try {
|
||||
const result = await getTicketResult(ticketId);
|
||||
setTicket(result.ticket);
|
||||
try {
|
||||
const config = await fetchTicketResourceConfig(result.ticket.project_id);
|
||||
setResourceConfig(config);
|
||||
} catch {
|
||||
setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG);
|
||||
}
|
||||
setWorktree(result.worktree);
|
||||
setTab("info");
|
||||
setDiff(null);
|
||||
setDiffError("");
|
||||
setDiffLoading(false);
|
||||
setAvailableBranches([]);
|
||||
setBranchInputMode("select");
|
||||
setTargetBranch("");
|
||||
setBranchesError("");
|
||||
setBranchesLoading(false);
|
||||
setBranchesLoadedForWorktreeId(null);
|
||||
|
||||
if (result.worktree && result.worktree.status === "Active") {
|
||||
if (latestTicketIdRef.current !== ticketId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTicket(result.ticket);
|
||||
|
||||
const config = await fetchTicketResourceConfig(result.ticket.project_id).catch(
|
||||
() => DEFAULT_TICKET_RESOURCE_CONFIG
|
||||
);
|
||||
if (latestTicketIdRef.current !== ticketId) {
|
||||
return;
|
||||
}
|
||||
setResourceConfig(config);
|
||||
|
||||
const worktreeSignature = result.worktree
|
||||
? `${result.worktree.id}:${result.worktree.status}`
|
||||
: "none";
|
||||
if (worktreeSignatureRef.current !== worktreeSignature) {
|
||||
worktreeSignatureRef.current = worktreeSignature;
|
||||
resetWorktreeUi();
|
||||
}
|
||||
|
||||
setWorktree(result.worktree);
|
||||
} catch (err) {
|
||||
if (latestTicketIdRef.current === ticketId) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
}, [resetWorktreeUi, ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
worktreeSignatureRef.current = "";
|
||||
setTab("info");
|
||||
resetWorktreeUi();
|
||||
void loadData();
|
||||
}, [ticketId]);
|
||||
}, [loadData, resetWorktreeUi]);
|
||||
|
||||
useLiveRefresh({
|
||||
enabled: Boolean(ticketId),
|
||||
projectId: ticket?.project_id,
|
||||
ticketId,
|
||||
refresh: loadData,
|
||||
fallbackIntervalMs: 7_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "info") return;
|
||||
|
|
@ -199,9 +240,10 @@ export default function TicketDetail() {
|
|||
await loadData();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!ticketId) return;
|
||||
|
|
@ -211,9 +253,10 @@ export default function TicketDetail() {
|
|||
await loadData();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApplyFix() {
|
||||
if (!worktree) return;
|
||||
|
|
@ -240,9 +283,10 @@ export default function TicketDetail() {
|
|||
await loadData();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteWorktree() {
|
||||
if (!worktree) return;
|
||||
|
|
@ -250,18 +294,13 @@ export default function TicketDetail() {
|
|||
setLoading(true);
|
||||
try {
|
||||
await deleteWorktreeCmd(worktree.id);
|
||||
setWorktree(null);
|
||||
setDiff(null);
|
||||
setAvailableBranches([]);
|
||||
setTargetBranch("");
|
||||
setBranchInputMode("select");
|
||||
setBranchesError("");
|
||||
setBranchesLoading(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return <div className="p-8 text-gray-400">Loading...</div>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { getProject, listProcessedTickets } from "../../lib/api";
|
||||
import {
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type TicketResourceConfig,
|
||||
} from "../../lib/ticketResource";
|
||||
import type { ProcessedTicket, Project } from "../../lib/types";
|
||||
import { useLiveRefresh } from "../../lib/useLiveRefresh";
|
||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||
import {
|
||||
cardContentClass,
|
||||
|
|
@ -24,37 +25,50 @@ export default function TicketList() {
|
|||
DEFAULT_TICKET_RESOURCE_CONFIG
|
||||
);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const latestProjectIdRef = useRef<string | undefined>(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
let cancelled = false;
|
||||
Promise.all([getProject(projectId), listProcessedTickets(projectId)])
|
||||
.then(([proj, tkts]) => {
|
||||
if (cancelled) return;
|
||||
latestProjectIdRef.current = projectId;
|
||||
}, [projectId]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setProject(null);
|
||||
setTickets([]);
|
||||
setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [proj, tkts, config] = await Promise.all([
|
||||
getProject(projectId),
|
||||
listProcessedTickets(projectId),
|
||||
fetchTicketResourceConfig(projectId).catch(() => DEFAULT_TICKET_RESOURCE_CONFIG),
|
||||
]);
|
||||
|
||||
if (latestProjectIdRef.current !== projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProject(proj);
|
||||
setTickets(tkts);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to load ticket list", error);
|
||||
});
|
||||
|
||||
void fetchTicketResourceConfig(projectId)
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setResourceConfig(config);
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load ticket list", error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useLiveRefresh({
|
||||
enabled: Boolean(projectId),
|
||||
projectId,
|
||||
refresh: loadData,
|
||||
fallbackIntervalMs: 8_000,
|
||||
});
|
||||
|
||||
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
|
||||
|
||||
return (
|
||||
|
|
|
|||
222
src/lib/useLiveRefresh.ts
Normal file
222
src/lib/useLiveRefresh.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface Scope {
|
||||
projectId?: string | null;
|
||||
ticketId?: string | null;
|
||||
}
|
||||
|
||||
interface UseLiveRefreshOptions extends Scope {
|
||||
enabled?: boolean;
|
||||
refresh: () => Promise<void> | void;
|
||||
fallbackIntervalMs?: number;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface EventScope {
|
||||
projectId?: string;
|
||||
ticketId?: string;
|
||||
}
|
||||
|
||||
interface EventDescriptor {
|
||||
name: string;
|
||||
extractScope: (payload: unknown) => EventScope;
|
||||
}
|
||||
|
||||
const LIVE_EVENTS: EventDescriptor[] = [
|
||||
{ name: "polling-started", extractScope: extractProjectScope },
|
||||
{ name: "polling-finished", extractScope: extractProjectScope },
|
||||
{ name: "polling-error", extractScope: extractProjectScope },
|
||||
{ name: "new-tickets-detected", extractScope: extractProjectScope },
|
||||
{ name: "ticket-processing-started", extractScope: extractTicketScope },
|
||||
{ name: "ticket-processing-done", extractScope: extractTicketScope },
|
||||
{ name: "ticket-processing-error", extractScope: extractTicketScope },
|
||||
{ name: "graylog-polling-started", extractScope: extractProjectScope },
|
||||
{ name: "graylog-polling-finished", extractScope: extractProjectScope },
|
||||
{ name: "graylog-polling-error", extractScope: extractProjectScope },
|
||||
{ name: "graylog-subject-triggered", extractScope: extractProjectScope },
|
||||
{ name: "new-notification", extractScope: extractNotificationScope },
|
||||
];
|
||||
|
||||
const DEFAULT_FALLBACK_INTERVAL_MS = 12_000;
|
||||
const DEFAULT_DEBOUNCE_MS = 300;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function extractProjectScope(payload: unknown): EventScope {
|
||||
const raw = asRecord(payload);
|
||||
return {
|
||||
projectId: asString(raw?.project_id),
|
||||
};
|
||||
}
|
||||
|
||||
function extractTicketScope(payload: unknown): EventScope {
|
||||
const raw = asRecord(payload);
|
||||
return {
|
||||
projectId: asString(raw?.project_id),
|
||||
ticketId: asString(raw?.ticket_id),
|
||||
};
|
||||
}
|
||||
|
||||
function extractNotificationScope(payload: unknown): EventScope {
|
||||
const raw = asRecord(payload);
|
||||
const notification = asRecord(raw?.notification);
|
||||
return {
|
||||
projectId: asString(notification?.project_id),
|
||||
ticketId: asString(notification?.ticket_id),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesScope(scope: Scope, eventScope: EventScope): boolean {
|
||||
if (scope.ticketId) {
|
||||
if (eventScope.ticketId === scope.ticketId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (scope.projectId && eventScope.projectId === scope.projectId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scope.projectId) {
|
||||
return eventScope.projectId === scope.projectId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useLiveRefresh({
|
||||
enabled = true,
|
||||
refresh,
|
||||
projectId,
|
||||
ticketId,
|
||||
fallbackIntervalMs = DEFAULT_FALLBACK_INTERVAL_MS,
|
||||
debounceMs = DEFAULT_DEBOUNCE_MS,
|
||||
}: UseLiveRefreshOptions): void {
|
||||
const refreshRef = useRef(refresh);
|
||||
const inFlightRef = useRef(false);
|
||||
const pendingRefreshRef = useRef(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
const runRefresh = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlightRef.current) {
|
||||
pendingRefreshRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightRef.current = true;
|
||||
void Promise.resolve(refreshRef.current())
|
||||
.catch((error: unknown) => {
|
||||
console.error("Live refresh failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
if (pendingRefreshRef.current) {
|
||||
pendingRefreshRef.current = false;
|
||||
runRefresh();
|
||||
}
|
||||
});
|
||||
}, [enabled]);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
(delayMs = debounceMs) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
runRefresh();
|
||||
}, delayMs);
|
||||
},
|
||||
[debounceMs, enabled, runRefresh]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let unlistenFns: Array<() => void> = [];
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const listeners = await Promise.all(
|
||||
LIVE_EVENTS.map((descriptor) =>
|
||||
listen<unknown>(descriptor.name, (event) => {
|
||||
if (
|
||||
!matchesScope({ projectId, ticketId }, descriptor.extractScope(event.payload))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scheduleRefresh();
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
listeners.forEach((unlisten) => unlisten());
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenFns = listeners;
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to subscribe to live refresh events", error);
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlistenFns.forEach((unlisten) => unlisten());
|
||||
};
|
||||
}, [enabled, projectId, scheduleRefresh, ticketId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || fallbackIntervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
runRefresh();
|
||||
}, fallbackIntervalMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [enabled, fallbackIntervalMs, runRefresh]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue