mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge branch 'v0.12.0rc' of github.com:AutoMaker-Org/automaker into v0.12.0rc
This commit is contained in:
@@ -22,6 +22,13 @@ export class ClaudeUsageService {
|
|||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
private isWindows = os.platform() === 'win32';
|
private isWindows = os.platform() === 'win32';
|
||||||
private isLinux = os.platform() === 'linux';
|
private isLinux = os.platform() === 'linux';
|
||||||
|
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||||
|
// Detect Electron by checking for electron-specific env vars or process properties
|
||||||
|
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
|
||||||
|
private isElectron =
|
||||||
|
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||||
|
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||||
|
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a PTY process with platform-specific handling.
|
* Kill a PTY process with platform-specific handling.
|
||||||
@@ -197,30 +204,87 @@ export class ClaudeUsageService {
|
|||||||
? ['/c', 'claude', '--add-dir', workingDirectory]
|
? ['/c', 'claude', '--add-dir', workingDirectory]
|
||||||
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
||||||
|
|
||||||
|
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let ptyProcess: any = null;
|
let ptyProcess: any = null;
|
||||||
|
|
||||||
|
// Build PTY spawn options
|
||||||
|
const ptyOptions: pty.IPtyForkOptions = {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: 120,
|
||||||
|
rows: 30,
|
||||||
|
cwd: workingDirectory,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
} as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// On Windows, always use winpty instead of ConPTY
|
||||||
|
// ConPTY requires AttachConsole which fails in many contexts:
|
||||||
|
// - Electron apps without a console
|
||||||
|
// - VS Code integrated terminal
|
||||||
|
// - Spawned from other applications
|
||||||
|
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||||
|
if (this.isWindows) {
|
||||||
|
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||||
|
logger.info(
|
||||||
|
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ptyProcess = pty.spawn(shell, args, {
|
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: workingDirectory,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
} as Record<string, string>,
|
|
||||||
});
|
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
|
||||||
|
|
||||||
// Return a user-friendly error instead of crashing
|
// Check for Windows ConPTY-specific errors
|
||||||
reject(
|
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||||
new Error(
|
// ConPTY failed - try winpty fallback
|
||||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
if (!this.useConptyFallback) {
|
||||||
)
|
logger.warn(
|
||||||
);
|
'[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
|
||||||
return;
|
);
|
||||||
|
this.useConptyFallback = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||||
|
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||||
|
logger.info(
|
||||||
|
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
|
||||||
|
);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
const fallbackMessage =
|
||||||
|
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
logger.error(
|
||||||
|
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
|
||||||
|
fallbackMessage
|
||||||
|
);
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
@@ -260,12 +324,19 @@ export class ClaudeUsageService {
|
|||||||
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||||
|
|
||||||
// Check for specific authentication/permission errors
|
// Check for specific authentication/permission errors
|
||||||
if (
|
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||||
cleanOutput.includes('OAuth token does not meet scope requirement') ||
|
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||||
cleanOutput.includes('permission_error') ||
|
const authChecks = {
|
||||||
cleanOutput.includes('token_expired') ||
|
oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
|
||||||
cleanOutput.includes('authentication_error')
|
tokenExpired: cleanOutput.includes('token_expired'),
|
||||||
) {
|
// Only match if it looks like a JSON API error response
|
||||||
|
authError:
|
||||||
|
cleanOutput.includes('"type":"authentication_error"') ||
|
||||||
|
cleanOutput.includes('"type": "authentication_error"'),
|
||||||
|
};
|
||||||
|
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
|
||||||
|
|
||||||
|
if (hasAuthError) {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
if (ptyProcess && !ptyProcess.killed) {
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
@@ -281,11 +352,16 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
||||||
if (
|
// Also check for percentage patterns that appear in usage output
|
||||||
!hasSeenUsageData &&
|
const hasUsageIndicators =
|
||||||
(cleanOutput.includes('Current session') ||
|
cleanOutput.includes('Current session') ||
|
||||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
|
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
||||||
) {
|
// Additional patterns for winpty - look for percentage patterns
|
||||||
|
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
||||||
|
cleanOutput.includes('Resets in') ||
|
||||||
|
cleanOutput.includes('Current week');
|
||||||
|
|
||||||
|
if (!hasSeenUsageData && hasUsageIndicators) {
|
||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -324,10 +400,18 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect REPL prompt and send /usage command
|
// Detect REPL prompt and send /usage command
|
||||||
if (
|
// On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators
|
||||||
!hasSentCommand &&
|
const isReplReady =
|
||||||
(cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts'))
|
cleanOutput.includes('❯') ||
|
||||||
) {
|
cleanOutput.includes('? for shortcuts') ||
|
||||||
|
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
|
||||||
|
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
|
||||||
|
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
|
||||||
|
// Detect model indicator which appears when REPL is ready
|
||||||
|
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
|
||||||
|
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
|
||||||
|
|
||||||
|
if (!hasSentCommand && isReplReady) {
|
||||||
hasSentCommand = true;
|
hasSentCommand = true;
|
||||||
// Wait for REPL to fully settle
|
// Wait for REPL to fully settle
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -364,11 +448,9 @@ export class ClaudeUsageService {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|
||||||
if (
|
// Check for auth errors - must be specific to avoid false positives
|
||||||
output.includes('token_expired') ||
|
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||||
output.includes('authentication_error') ||
|
if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
|
||||||
output.includes('permission_error')
|
|
||||||
) {
|
|
||||||
reject(new Error("Authentication required - please run 'claude login'"));
|
reject(new Error("Authentication required - please run 'claude login'"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export class TerminalService extends EventEmitter {
|
|||||||
private dataCallbacks: Set<DataCallback> = new Set();
|
private dataCallbacks: Set<DataCallback> = new Set();
|
||||||
private exitCallbacks: Set<ExitCallback> = new Set();
|
private exitCallbacks: Set<ExitCallback> = new Set();
|
||||||
private isWindows = os.platform() === 'win32';
|
private isWindows = os.platform() === 'win32';
|
||||||
|
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||||
|
// Detect Electron by checking for electron-specific env vars or process properties
|
||||||
|
private isElectron =
|
||||||
|
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||||
|
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||||
|
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a PTY process with platform-specific handling.
|
* Kill a PTY process with platform-specific handling.
|
||||||
@@ -339,13 +345,60 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
// Build PTY spawn options
|
||||||
|
const ptyOptions: pty.IPtyForkOptions = {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: options.cols || 80,
|
cols: options.cols || 80,
|
||||||
rows: options.rows || 24,
|
rows: options.rows || 24,
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// On Windows, always use winpty instead of ConPTY
|
||||||
|
// ConPTY requires AttachConsole which fails in many contexts:
|
||||||
|
// - Electron apps without a console
|
||||||
|
// - VS Code integrated terminal
|
||||||
|
// - Spawned from other applications
|
||||||
|
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||||
|
if (this.isWindows) {
|
||||||
|
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||||
|
logger.info(
|
||||||
|
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ptyProcess: pty.IPty;
|
||||||
|
try {
|
||||||
|
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||||
|
} catch (spawnError) {
|
||||||
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
|
|
||||||
|
// Check for Windows ConPTY-specific errors
|
||||||
|
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||||
|
// ConPTY failed - try winpty fallback
|
||||||
|
if (!this.useConptyFallback) {
|
||||||
|
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
|
||||||
|
this.useConptyFallback = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||||
|
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||||
|
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
const fallbackMessage =
|
||||||
|
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||||
|
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session: TerminalSession = {
|
const session: TerminalSession = {
|
||||||
id,
|
id,
|
||||||
@@ -409,7 +462,11 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
// Handle exit
|
// Handle exit
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
const exitMessage =
|
||||||
|
exitCode === undefined || exitCode === null
|
||||||
|
? 'Session terminated'
|
||||||
|
: `Session exited with code ${exitCode}`;
|
||||||
|
logger.info(`${exitMessage} (${id})`);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||||
this.emit('exit', id, exitCode);
|
this.emit('exit', id, exitCode);
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { ErrorState } from '@/components/ui/error-state';
|
import { ErrorState } from '@/components/ui/error-state';
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
|
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||||
import { useModelOverride } from '@/components/shared';
|
import { useModelOverride } from '@/components/shared';
|
||||||
import type { ValidateIssueOptions } from './github-issues-view/types';
|
import type {
|
||||||
|
ValidateIssueOptions,
|
||||||
|
IssuesFilterState,
|
||||||
|
IssuesStateFilter,
|
||||||
|
} from './github-issues-view/types';
|
||||||
|
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
|
||||||
|
|
||||||
const logger = createLogger('GitHubIssuesView');
|
const logger = createLogger('GitHubIssuesView');
|
||||||
|
|
||||||
@@ -26,6 +32,9 @@ export function GitHubIssuesView() {
|
|||||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||||
useState<ValidateIssueOptions | null>(null);
|
useState<ValidateIssueOptions | null>(null);
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
|
||||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||||
|
|
||||||
// Model override for validation
|
// Model override for validation
|
||||||
@@ -44,6 +53,41 @@ export function GitHubIssuesView() {
|
|||||||
onShowValidationDialogChange: setShowValidationDialog,
|
onShowValidationDialogChange: setShowValidationDialog,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Combine all issues for filtering
|
||||||
|
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
|
||||||
|
|
||||||
|
// Apply filter to issues - now returns matched issues directly for better performance
|
||||||
|
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
|
||||||
|
|
||||||
|
// Separate filtered issues by state - this is O(n) but now only done once
|
||||||
|
// since filterResult.matchedIssues already contains the filtered issues
|
||||||
|
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
|
||||||
|
const open: typeof openIssues = [];
|
||||||
|
const closed: typeof closedIssues = [];
|
||||||
|
for (const issue of filterResult.matchedIssues) {
|
||||||
|
if (issue.state.toLowerCase() === 'open') {
|
||||||
|
open.push(issue);
|
||||||
|
} else {
|
||||||
|
closed.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { filteredOpenIssues: open, filteredClosedIssues: closed };
|
||||||
|
}, [filterResult.matchedIssues]);
|
||||||
|
|
||||||
|
// Filter state change handlers
|
||||||
|
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
|
||||||
|
setFilterState((prev) => ({ ...prev, stateFilter }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
|
||||||
|
setFilterState((prev) => ({ ...prev, selectedLabels }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear all filters to default state
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get current branch from selected worktree
|
// Get current branch from selected worktree
|
||||||
const currentBranch = useMemo(() => {
|
const currentBranch = useMemo(() => {
|
||||||
if (!currentProject?.path) return '';
|
if (!currentProject?.path) return '';
|
||||||
@@ -130,7 +174,10 @@ export function GitHubIssuesView() {
|
|||||||
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalIssues = openIssues.length + closedIssues.length;
|
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
|
||||||
|
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
|
||||||
|
const isFilteredEmpty =
|
||||||
|
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
@@ -143,10 +190,21 @@ export function GitHubIssuesView() {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<IssuesListHeader
|
<IssuesListHeader
|
||||||
openCount={openIssues.length}
|
openCount={filteredOpenIssues.length}
|
||||||
closedCount={closedIssues.length}
|
closedCount={filteredClosedIssues.length}
|
||||||
|
totalOpenCount={openIssues.length}
|
||||||
|
totalClosedCount={closedIssues.length}
|
||||||
|
hasActiveFilter={filterResult.hasActiveFilter}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
compact={!!selectedIssue}
|
||||||
|
filterProps={{
|
||||||
|
stateFilter: filterState.stateFilter,
|
||||||
|
selectedLabels: filterState.selectedLabels,
|
||||||
|
availableLabels: filterResult.availableLabels,
|
||||||
|
onStateFilterChange: handleStateFilterChange,
|
||||||
|
onLabelsChange: handleLabelsChange,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Issues List */}
|
{/* Issues List */}
|
||||||
@@ -154,15 +212,35 @@ export function GitHubIssuesView() {
|
|||||||
{totalIssues === 0 ? (
|
{totalIssues === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||||
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
{isFilteredEmpty ? (
|
||||||
|
<SearchX className="h-8 w-8 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<CircleDot className="h-8 w-8 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-medium mb-2">No Issues</h2>
|
<h2 className="text-base font-medium mb-2">
|
||||||
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
|
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{isFilteredEmpty
|
||||||
|
? 'No issues match your current filters.'
|
||||||
|
: 'This repository has no issues yet.'}
|
||||||
|
</p>
|
||||||
|
{isFilteredEmpty && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{/* Open Issues */}
|
{/* Open Issues */}
|
||||||
{openIssues.map((issue) => (
|
{filteredOpenIssues.map((issue) => (
|
||||||
<IssueRow
|
<IssueRow
|
||||||
key={issue.number}
|
key={issue.number}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@@ -176,12 +254,12 @@ export function GitHubIssuesView() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Closed Issues Section */}
|
{/* Closed Issues Section */}
|
||||||
{closedIssues.length > 0 && (
|
{filteredClosedIssues.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
|
||||||
Closed Issues ({closedIssues.length})
|
Closed Issues ({filteredClosedIssues.length})
|
||||||
</div>
|
</div>
|
||||||
{closedIssues.map((issue) => (
|
{filteredClosedIssues.map((issue) => (
|
||||||
<IssueRow
|
<IssueRow
|
||||||
key={issue.number}
|
key={issue.number}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { IssueRow } from './issue-row';
|
export { IssueRow } from './issue-row';
|
||||||
export { IssueDetailPanel } from './issue-detail-panel';
|
export { IssueDetailPanel } from './issue-detail-panel';
|
||||||
export { IssuesListHeader } from './issues-list-header';
|
export { IssuesListHeader } from './issues-list-header';
|
||||||
|
export { IssuesFilterControls } from './issues-filter-controls';
|
||||||
export { CommentItem } from './comment-item';
|
export { CommentItem } from './comment-item';
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { ChevronDown, Tag, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { IssuesStateFilter } from '../types';
|
||||||
|
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
|
||||||
|
|
||||||
|
/** Maximum number of labels to display before showing "+N more" in normal layout */
|
||||||
|
const VISIBLE_LABELS_LIMIT = 3;
|
||||||
|
/** Maximum number of labels to display before showing "+N more" in compact layout */
|
||||||
|
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
|
||||||
|
|
||||||
|
interface IssuesFilterControlsProps {
|
||||||
|
/** Current state filter value */
|
||||||
|
stateFilter: IssuesStateFilter;
|
||||||
|
/** Currently selected labels */
|
||||||
|
selectedLabels: string[];
|
||||||
|
/** Available labels to choose from (typically from useIssuesFilter result) */
|
||||||
|
availableLabels: string[];
|
||||||
|
/** Callback when state filter changes */
|
||||||
|
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||||
|
/** Callback when labels selection changes */
|
||||||
|
onLabelsChange: (labels: string[]) => void;
|
||||||
|
/** Whether the controls are disabled (e.g., during loading) */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Whether to use compact layout (stacked vertically) */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Additional class name for the container */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable labels for state filter options */
|
||||||
|
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
closed: 'Closed',
|
||||||
|
all: 'All',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IssuesFilterControls({
|
||||||
|
stateFilter,
|
||||||
|
selectedLabels,
|
||||||
|
availableLabels,
|
||||||
|
onStateFilterChange,
|
||||||
|
onLabelsChange,
|
||||||
|
disabled = false,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: IssuesFilterControlsProps) {
|
||||||
|
/**
|
||||||
|
* Handles toggling a label in the selection.
|
||||||
|
* If the label is already selected, it removes it; otherwise, it adds it.
|
||||||
|
*/
|
||||||
|
const handleLabelToggle = (label: string) => {
|
||||||
|
const isSelected = selectedLabels.includes(label);
|
||||||
|
if (isSelected) {
|
||||||
|
onLabelsChange(selectedLabels.filter((l) => l !== label));
|
||||||
|
} else {
|
||||||
|
onLabelsChange([...selectedLabels, label]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all selected labels.
|
||||||
|
*/
|
||||||
|
const handleClearLabels = () => {
|
||||||
|
onLabelsChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSelectedLabels = selectedLabels.length > 0;
|
||||||
|
const hasAvailableLabels = availableLabels.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-2', className)}>
|
||||||
|
{/* Filter Controls Row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* State Filter Select */}
|
||||||
|
<Select
|
||||||
|
value={stateFilter}
|
||||||
|
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
|
||||||
|
<SelectValue placeholder="State" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{STATE_FILTER_LABELS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Labels Filter Dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
|
||||||
|
disabled={disabled || !hasAvailableLabels}
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5" />
|
||||||
|
<span>Labels</span>
|
||||||
|
{hasSelectedLabels && (
|
||||||
|
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
|
||||||
|
{selectedLabels.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
<span>Filter by label</span>
|
||||||
|
{hasSelectedLabels && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleClearLabels}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-0.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{availableLabels.map((label) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={label}
|
||||||
|
checked={selectedLabels.includes(label)}
|
||||||
|
onCheckedChange={() => handleLabelToggle(label)}
|
||||||
|
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
{!hasAvailableLabels && (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Labels Display - shown on separate row */}
|
||||||
|
{hasSelectedLabels && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{selectedLabels
|
||||||
|
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
|
||||||
|
.map((label) => (
|
||||||
|
<Badge
|
||||||
|
key={label}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
|
||||||
|
onClick={() => handleLabelToggle(label)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{selectedLabels.length >
|
||||||
|
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
|
||||||
|
<Badge variant="muted" size="sm">
|
||||||
|
+
|
||||||
|
{selectedLabels.length -
|
||||||
|
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
|
||||||
|
more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +1,100 @@
|
|||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { IssuesStateFilter } from '../types';
|
||||||
|
import { IssuesFilterControls } from './issues-filter-controls';
|
||||||
|
|
||||||
interface IssuesListHeaderProps {
|
interface IssuesListHeaderProps {
|
||||||
openCount: number;
|
openCount: number;
|
||||||
closedCount: number;
|
closedCount: number;
|
||||||
|
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||||
|
totalOpenCount?: number;
|
||||||
|
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
|
||||||
|
totalClosedCount?: number;
|
||||||
|
/** Whether any filter is currently active */
|
||||||
|
hasActiveFilter?: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
/** Whether the list is in compact mode (e.g., when detail panel is open) */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Optional filter state and handlers - when provided, filter controls are rendered */
|
||||||
|
filterProps?: {
|
||||||
|
stateFilter: IssuesStateFilter;
|
||||||
|
selectedLabels: string[];
|
||||||
|
availableLabels: string[];
|
||||||
|
onStateFilterChange: (filter: IssuesStateFilter) => void;
|
||||||
|
onLabelsChange: (labels: string[]) => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IssuesListHeader({
|
export function IssuesListHeader({
|
||||||
openCount,
|
openCount,
|
||||||
closedCount,
|
closedCount,
|
||||||
|
totalOpenCount,
|
||||||
|
totalClosedCount,
|
||||||
|
hasActiveFilter = false,
|
||||||
refreshing,
|
refreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
compact = false,
|
||||||
|
filterProps,
|
||||||
}: IssuesListHeaderProps) {
|
}: IssuesListHeaderProps) {
|
||||||
const totalIssues = openCount + closedCount;
|
const totalIssues = openCount + closedCount;
|
||||||
|
|
||||||
|
// Format the counts subtitle based on filter state
|
||||||
|
const getCountsSubtitle = () => {
|
||||||
|
if (totalIssues === 0) {
|
||||||
|
return hasActiveFilter ? 'No matching issues' : 'No issues found';
|
||||||
|
}
|
||||||
|
|
||||||
|
// When filters are active and we have total counts, show "X of Y" format
|
||||||
|
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
|
||||||
|
const openText =
|
||||||
|
openCount === totalOpenCount
|
||||||
|
? `${openCount} open`
|
||||||
|
: `${openCount} of ${totalOpenCount} open`;
|
||||||
|
const closedText =
|
||||||
|
closedCount === totalClosedCount
|
||||||
|
? `${closedCount} closed`
|
||||||
|
: `${closedCount} of ${totalClosedCount} closed`;
|
||||||
|
return `${openText}, ${closedText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default format when no filters active
|
||||||
|
return `${openCount} open, ${closedCount} closed`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
{/* Top row: Title and refresh button */}
|
||||||
<div className="p-2 rounded-lg bg-green-500/10">
|
<div className="flex items-center justify-between p-4 pb-2">
|
||||||
<CircleDot className="h-5 w-5 text-green-500" />
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="p-2 rounded-lg bg-green-500/10">
|
||||||
<div>
|
<CircleDot className="h-5 w-5 text-green-500" />
|
||||||
<h1 className="text-lg font-bold">Issues</h1>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div>
|
||||||
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
|
<h1 className="text-lg font-bold">Issues</h1>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
|
|
||||||
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
|
{/* Filter controls row (optional) */}
|
||||||
</Button>
|
{filterProps && (
|
||||||
|
<div className="px-4 pb-3 pt-1">
|
||||||
|
<IssuesFilterControls
|
||||||
|
stateFilter={filterProps.stateFilter}
|
||||||
|
selectedLabels={filterProps.selectedLabels}
|
||||||
|
availableLabels={filterProps.availableLabels}
|
||||||
|
onStateFilterChange={filterProps.onStateFilterChange}
|
||||||
|
onLabelsChange={filterProps.onLabelsChange}
|
||||||
|
disabled={refreshing}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { useGithubIssues } from './use-github-issues';
|
export { useGithubIssues } from './use-github-issues';
|
||||||
export { useIssueValidation } from './use-issue-validation';
|
export { useIssueValidation } from './use-issue-validation';
|
||||||
export { useIssueComments } from './use-issue-comments';
|
export { useIssueComments } from './use-issue-comments';
|
||||||
|
export { useIssuesFilter } from './use-issues-filter';
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
|
||||||
|
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
|
||||||
|
import { isValidationStale } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the validation status of an issue based on its cached validation.
|
||||||
|
*/
|
||||||
|
function getValidationStatus(
|
||||||
|
issueNumber: number,
|
||||||
|
cachedValidations: Map<number, StoredValidation>
|
||||||
|
): IssuesValidationStatus | null {
|
||||||
|
const validation = cachedValidations.get(issueNumber);
|
||||||
|
if (!validation) {
|
||||||
|
return 'not_validated';
|
||||||
|
}
|
||||||
|
if (isValidationStale(validation.validatedAt)) {
|
||||||
|
return 'stale';
|
||||||
|
}
|
||||||
|
return 'validated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a search query matches an issue's searchable content.
|
||||||
|
* Searches through title and body (case-insensitive).
|
||||||
|
*/
|
||||||
|
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
|
||||||
|
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
|
||||||
|
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
|
||||||
|
|
||||||
|
return titleMatch || bodyMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an issue matches the state filter (open/closed/all).
|
||||||
|
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
|
||||||
|
*/
|
||||||
|
function matchesStateFilter(
|
||||||
|
issue: GitHubIssue,
|
||||||
|
stateFilter: IssuesFilterState['stateFilter']
|
||||||
|
): boolean {
|
||||||
|
if (stateFilter === 'all') return true;
|
||||||
|
return issue.state.toLowerCase() === stateFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an issue matches any of the selected labels.
|
||||||
|
* Returns true if no labels are selected (no filter) or if any selected label matches.
|
||||||
|
*/
|
||||||
|
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
|
||||||
|
if (selectedLabels.length === 0) return true;
|
||||||
|
|
||||||
|
const issueLabels = issue.labels.map((l) => l.name);
|
||||||
|
return selectedLabels.some((label) => issueLabels.includes(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an issue matches any of the selected assignees.
|
||||||
|
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
|
||||||
|
*/
|
||||||
|
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
|
||||||
|
if (selectedAssignees.length === 0) return true;
|
||||||
|
|
||||||
|
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
|
||||||
|
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an issue matches any of the selected milestones.
|
||||||
|
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
|
||||||
|
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
|
||||||
|
*/
|
||||||
|
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
|
||||||
|
if (selectedMilestones.length === 0) return true;
|
||||||
|
|
||||||
|
// GitHub issues in the current schema don't have milestone field
|
||||||
|
// This is a placeholder for future milestone support
|
||||||
|
// For now, issues with no milestone won't match if a milestone filter is active
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an issue matches the validation status filter.
|
||||||
|
*/
|
||||||
|
function matchesValidationStatus(
|
||||||
|
issue: GitHubIssue,
|
||||||
|
validationStatusFilter: IssuesValidationStatus | null,
|
||||||
|
cachedValidations: Map<number, StoredValidation>
|
||||||
|
): boolean {
|
||||||
|
if (!validationStatusFilter) return true;
|
||||||
|
|
||||||
|
const status = getValidationStatus(issue.number, cachedValidations);
|
||||||
|
return status === validationStatusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all unique labels from a list of issues.
|
||||||
|
*/
|
||||||
|
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
|
||||||
|
const labelsSet = new Set<string>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
for (const label of issue.labels) {
|
||||||
|
labelsSet.add(label.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(labelsSet).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all unique assignees from a list of issues.
|
||||||
|
*/
|
||||||
|
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
|
||||||
|
const assigneesSet = new Set<string>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
for (const assignee of issue.assignees ?? []) {
|
||||||
|
assigneesSet.add(assignee.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(assigneesSet).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts all unique milestones from a list of issues.
|
||||||
|
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
|
||||||
|
*/
|
||||||
|
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
|
||||||
|
// GitHub issues in the current schema don't have milestone field
|
||||||
|
// This is a placeholder for future milestone support
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if any filter is currently active.
|
||||||
|
*/
|
||||||
|
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
stateFilter,
|
||||||
|
selectedLabels,
|
||||||
|
selectedAssignees,
|
||||||
|
selectedMilestones,
|
||||||
|
validationStatusFilter,
|
||||||
|
} = filterState;
|
||||||
|
|
||||||
|
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
|
||||||
|
// Only 'closed' or 'all' are considered active filters
|
||||||
|
const hasStateFilter = stateFilter !== 'open';
|
||||||
|
const hasSearchQuery = searchQuery.trim().length > 0;
|
||||||
|
const hasLabelFilter = selectedLabels.length > 0;
|
||||||
|
const hasAssigneeFilter = selectedAssignees.length > 0;
|
||||||
|
const hasMilestoneFilter = selectedMilestones.length > 0;
|
||||||
|
const hasValidationFilter = validationStatusFilter !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
hasSearchQuery ||
|
||||||
|
hasStateFilter ||
|
||||||
|
hasLabelFilter ||
|
||||||
|
hasAssigneeFilter ||
|
||||||
|
hasMilestoneFilter ||
|
||||||
|
hasValidationFilter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to filter GitHub issues based on the current filter state.
|
||||||
|
*
|
||||||
|
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
|
||||||
|
* It computes matched issues and extracts available filter options from all issues.
|
||||||
|
*
|
||||||
|
* @param issues - Combined array of all issues (open + closed) to filter
|
||||||
|
* @param filterState - Current filter state including search, labels, assignees, etc.
|
||||||
|
* @param cachedValidations - Map of issue numbers to their cached validation results
|
||||||
|
* @returns Filter result containing matched issue numbers and available filter options
|
||||||
|
*/
|
||||||
|
export function useIssuesFilter(
|
||||||
|
issues: GitHubIssue[],
|
||||||
|
filterState: IssuesFilterState,
|
||||||
|
cachedValidations: Map<number, StoredValidation> = new Map()
|
||||||
|
): IssuesFilterResult {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
stateFilter,
|
||||||
|
selectedLabels,
|
||||||
|
selectedAssignees,
|
||||||
|
selectedMilestones,
|
||||||
|
validationStatusFilter,
|
||||||
|
} = filterState;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// Extract available options from all issues (for filter dropdown population)
|
||||||
|
const availableLabels = extractAvailableLabels(issues);
|
||||||
|
const availableAssignees = extractAvailableAssignees(issues);
|
||||||
|
const availableMilestones = extractAvailableMilestones(issues);
|
||||||
|
|
||||||
|
// Check if any filter is active
|
||||||
|
const hasActiveFilter = hasActiveFilterCheck(filterState);
|
||||||
|
|
||||||
|
// Normalize search query for case-insensitive matching
|
||||||
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Filter issues based on all criteria - return matched issues directly
|
||||||
|
// This eliminates the redundant O(n) filtering operation in the consuming component
|
||||||
|
const matchedIssues: GitHubIssue[] = [];
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
// All conditions must be true for a match
|
||||||
|
const matchesAllFilters =
|
||||||
|
matchesSearchQuery(issue, normalizedQuery) &&
|
||||||
|
matchesStateFilter(issue, stateFilter) &&
|
||||||
|
matchesLabels(issue, selectedLabels) &&
|
||||||
|
matchesAssignees(issue, selectedAssignees) &&
|
||||||
|
matchesMilestones(issue, selectedMilestones) &&
|
||||||
|
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
|
||||||
|
|
||||||
|
if (matchesAllFilters) {
|
||||||
|
matchedIssues.push(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchedIssues,
|
||||||
|
availableLabels,
|
||||||
|
availableAssignees,
|
||||||
|
availableMilestones,
|
||||||
|
hasActiveFilter,
|
||||||
|
matchedCount: matchedIssues.length,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
issues,
|
||||||
|
searchQuery,
|
||||||
|
stateFilter,
|
||||||
|
selectedLabels,
|
||||||
|
selectedAssignees,
|
||||||
|
selectedMilestones,
|
||||||
|
validationStatusFilter,
|
||||||
|
cachedValidations,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,111 @@
|
|||||||
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
|
||||||
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Issues Filter State Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available sort columns for issues list
|
||||||
|
*/
|
||||||
|
export const ISSUES_SORT_COLUMNS = [
|
||||||
|
'title',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'comments',
|
||||||
|
'number',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort direction options
|
||||||
|
*/
|
||||||
|
export type IssuesSortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available issue state filter values
|
||||||
|
*/
|
||||||
|
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
|
||||||
|
|
||||||
|
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation status filter values for filtering issues by validation state
|
||||||
|
*/
|
||||||
|
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
|
||||||
|
|
||||||
|
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort configuration for issues list
|
||||||
|
*/
|
||||||
|
export interface IssuesSortConfig {
|
||||||
|
column: IssuesSortColumn;
|
||||||
|
direction: IssuesSortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main filter state interface for the GitHub Issues view
|
||||||
|
*
|
||||||
|
* This interface defines all filterable/sortable state for the issues list.
|
||||||
|
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
|
||||||
|
*/
|
||||||
|
export interface IssuesFilterState {
|
||||||
|
/** Search query for filtering by issue title or body */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Filter by issue state (open/closed/all) */
|
||||||
|
stateFilter: IssuesStateFilter;
|
||||||
|
/** Filter by selected labels (matches any) */
|
||||||
|
selectedLabels: string[];
|
||||||
|
/** Filter by selected assignees (matches any) */
|
||||||
|
selectedAssignees: string[];
|
||||||
|
/** Filter by selected milestones (matches any) */
|
||||||
|
selectedMilestones: string[];
|
||||||
|
/** Filter by validation status */
|
||||||
|
validationStatusFilter: IssuesValidationStatus | null;
|
||||||
|
/** Current sort configuration */
|
||||||
|
sortConfig: IssuesSortConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of applying filters to the issues list
|
||||||
|
*/
|
||||||
|
export interface IssuesFilterResult {
|
||||||
|
/** Array of GitHubIssue objects that match the current filters */
|
||||||
|
matchedIssues: GitHubIssue[];
|
||||||
|
/** Available labels from all issues (for filter dropdown population) */
|
||||||
|
availableLabels: string[];
|
||||||
|
/** Available assignees from all issues (for filter dropdown population) */
|
||||||
|
availableAssignees: string[];
|
||||||
|
/** Available milestones from all issues (for filter dropdown population) */
|
||||||
|
availableMilestones: string[];
|
||||||
|
/** Whether any filter is currently active */
|
||||||
|
hasActiveFilter: boolean;
|
||||||
|
/** Total count of matched issues */
|
||||||
|
matchedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default values for IssuesFilterState
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
|
||||||
|
searchQuery: '',
|
||||||
|
stateFilter: 'open',
|
||||||
|
selectedLabels: [],
|
||||||
|
selectedAssignees: [],
|
||||||
|
selectedMilestones: [],
|
||||||
|
validationStatusFilter: null,
|
||||||
|
sortConfig: {
|
||||||
|
column: 'updated_at',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component Props Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export interface IssueRowProps {
|
export interface IssueRowProps {
|
||||||
issue: GitHubIssue;
|
issue: GitHubIssue;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user