Skip to content
60 changes: 60 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,66 @@ export async function updateCacheWithRepository(token: string, repository: RepoN
} catch {}
}

export async function updateCacheAfterRename(
token: string,
repositoryId: string,
newName: string,
nameWithOwner: string
): Promise<void> {
try {
const ap = await makeApolloClient(token);
if (!ap || !ap.client) return;

// Update the repository in cache
ap.client.cache.modify({
id: `Repository:${repositoryId}`,
fields: {
name: () => newName,
nameWithOwner: () => nameWithOwner
}
});
} catch {}
}

export async function renameRepositoryById(
client: ReturnType<typeof makeClient>,
repositoryId: string,
newName: string
): Promise<void> {
logger.info('Renaming repository', {
repositoryId,
newName
});

const mutation = /* GraphQL */ `
mutation RenameRepo($repositoryId: ID!, $name: String!) {
updateRepository(input: { repositoryId: $repositoryId, name: $name }) {
repository {
id
name
nameWithOwner
}
}
}
`;

try {
const result = await client(mutation, { repositoryId, name: newName });

logger.info('Repository renamed successfully', {
repositoryId,
newName: result?.updateRepository?.repository?.name
});
} catch (error: any) {
logger.error('Failed to rename repository', {
repositoryId,
newName,
error: error.message
});
throw error;
}
}

// Debug function to inspect cache status - using stderr to bypass Ink UI
export async function inspectCacheStatus(): Promise<void> {
try {
Expand Down
68 changes: 62 additions & 6 deletions src/ui/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { Box, Text, useApp, useInput, useStdout, Spacer, Newline } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
import { makeClient, fetchViewerReposPageUnified, searchRepositoriesUnified, deleteRepositoryRest, archiveRepositoryById, unarchiveRepositoryById, changeRepositoryVisibility, syncForkWithUpstream, getRepositoryFromCache, purgeApolloCacheFiles, inspectCacheStatus, updateCacheAfterDelete, updateCacheAfterArchive, updateCacheAfterVisibilityChange, updateCacheWithRepository, checkOrganizationIsEnterprise, OwnerAffiliation, fetchViewerOrganizations } from '../github';
import { makeClient, fetchViewerReposPageUnified, searchRepositoriesUnified, deleteRepositoryRest, archiveRepositoryById, unarchiveRepositoryById, changeRepositoryVisibility, syncForkWithUpstream, getRepositoryFromCache, purgeApolloCacheFiles, inspectCacheStatus, updateCacheAfterDelete, updateCacheAfterArchive, updateCacheAfterVisibilityChange, updateCacheWithRepository, checkOrganizationIsEnterprise, OwnerAffiliation, fetchViewerOrganizations, renameRepositoryById, updateCacheAfterRename } from '../github';
import { getUIPrefs, storeUIPrefs, OwnerContext } from '../config';
import { makeApolloKey, makeSearchKey, isFresh, markFetched } from '../apolloMeta';
import type { RepoNode, RateLimitInfo } from '../types';
import { exec } from 'child_process';
import OrgSwitcher from './OrgSwitcher';
import { logger } from '../logger';
import { DeleteModal, ArchiveModal, SyncModal, InfoModal, LogoutModal, VisibilityModal, SortModal, ChangeVisibilityModal } from './components/modals';
import { DeleteModal, ArchiveModal, SyncModal, InfoModal, LogoutModal, VisibilityModal, SortModal, ChangeVisibilityModal, RenameModal } from './components/modals';
import { RepoRow, FilterInput, RepoListHeader } from './components/repo';
import { SlowSpinner } from './components/common';
import { truncate, formatDate } from '../utils';
Expand Down Expand Up @@ -114,6 +114,9 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
const [archiveError, setArchiveError] = useState<string | null>(null);
const [archiveFocus, setArchiveFocus] = useState<'confirm' | 'cancel'>('confirm');

// Rename modal state
const [renameMode, setRenameMode] = useState(false);
const [renameTarget, setRenameTarget] = useState<RepoNode | null>(null);
// Sync modal state
const [syncMode, setSyncMode] = useState(false);
const [syncTarget, setSyncTarget] = useState<RepoNode | null>(null);
Expand Down Expand Up @@ -184,6 +187,11 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
setArchiveError(null);
setArchiveFocus('confirm');
}

function closeRenameModal() {
setRenameMode(false);
setRenameTarget(null);
}

function closeChangeVisibilityModal() {
setChangeVisibilityMode(false);
Expand Down Expand Up @@ -281,6 +289,30 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
// Keep modal open on error
}
}

async function executeRename(repo: RepoNode, newName: string) {
if (!repo || !newName.trim()) return;

try {
const id = (repo as any).id;
const owner = repo.nameWithOwner.split('/')[0];
const newNameWithOwner = `${owner}/${newName}`;

await renameRepositoryById(client, id, newName);

// Update Apollo cache
await updateCacheAfterRename(token, id, newName, newNameWithOwner);

// Update both regular items and search items
const updateRepo = (r: any) => (r.id === id ? { ...r, name: newName, nameWithOwner: newNameWithOwner } : r);
setItems(prev => prev.map(updateRepo));
setSearchItems(prev => prev.map(updateRepo));

closeRenameModal();
} catch (error: any) {
throw error; // Let the modal handle the error
}
}

// Handler for changing visibility
async function handleVisibilityChange(newVisibility: string) {
Expand Down Expand Up @@ -804,7 +836,7 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
return;
}
// Retry on 'R'
if (input && input.toUpperCase() === 'R') {
if (input && input.toUpperCase() === 'R' && !key.ctrl) {
setCursor(0);
setRefreshing(true);
setSortingLoading(true);
Expand Down Expand Up @@ -859,6 +891,12 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
return;
}

// When in rename mode, trap ALL inputs for modal
// The modal's TextInput will handle the input
if (renameMode) {
return; // Let the modal handle everything
}

// When in archive mode, trap inputs for modal
if (archiveMode) {
if (key.escape || (input && input.toUpperCase() === 'C')) {
Expand Down Expand Up @@ -1038,7 +1076,7 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
setCursor(visibleItems.length - 1);
return;
}
if (input && input.toUpperCase() === 'R') {
if (input && input.toUpperCase() === 'R' && !key.ctrl) {
// Refresh - show loading screen
setCursor(0);
setRefreshing(true);
Expand Down Expand Up @@ -1068,6 +1106,16 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
return;
}

// Rename modal (Ctrl+R)
if (key.ctrl && (input === 'r' || input === 'R')) {
const repo = visibleItems[cursor];
if (repo) {
setRenameTarget(repo);
setRenameMode(true);
}
return;
}

// Change visibility modal (Ctrl+V)
if (key.ctrl && (input === 'v' || input === 'V')) {
const repo = visibleItems[cursor];
Expand Down Expand Up @@ -1345,7 +1393,7 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
}

const lowRate = rateLimit && rateLimit.remaining <= Math.ceil(rateLimit.limit * 0.1);
const modalOpen = deleteMode || archiveMode || syncMode || logoutMode || infoMode || visibilityMode;
const modalOpen = deleteMode || archiveMode || syncMode || logoutMode || infoMode || visibilityMode || renameMode;

// Memoize header to prevent re-renders - must be before any returns
const headerBar = useMemo(() => (
Expand Down Expand Up @@ -1669,6 +1717,14 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
)}
</Box>
</Box>
) : renameMode && renameTarget ? (
<Box height={contentHeight} alignItems="center" justifyContent="center">
<RenameModal
repo={renameTarget}
onRename={executeRename}
onCancel={closeRenameModal}
/>
</Box>
Comment thread
cursor[bot] marked this conversation as resolved.
) : syncMode && syncTarget ? (
<Box height={contentHeight} alignItems="center" justifyContent="center">
<Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={3} paddingY={2} width={Math.min(terminalWidth - 8, 80)}>
Expand Down Expand Up @@ -2001,7 +2057,7 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin,
{/* Line 3: Action controls */}
<Box width={terminalWidth} justifyContent="center">
<Text color="gray" dimColor={modalOpen ? true : undefined}>
I Info • K Cache Info • Ctrl+A Un/Archive • Ctrl+V Change Visibility • Del/Backspace Delete • Ctrl+S Sync Fork
I Info • K Cache Info • Ctrl+R Rename • Ctrl+A Un/Archive • Ctrl+V Change Visibility • Del/Backspace Delete • Ctrl+S Sync Fork
</Text>
</Box>
</Box>
Expand Down
129 changes: 129 additions & 0 deletions src/ui/components/modals/RenameModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
import type { RepoNode } from '../../../types';
import { SlowSpinner } from '../common';

interface RenameModalProps {
repo: RepoNode | null;
onRename: (repo: RepoNode, newName: string) => Promise<void>;
onCancel: () => void;
}

export default function RenameModal({ repo, onRename, onCancel }: RenameModalProps) {
const [newName, setNewName] = useState('');
const [renaming, setRenaming] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);

// Initialize with current repo name when modal opens
useEffect(() => {
if (repo) {
setNewName(repo.name);
setRenameError(null);
}
}, [repo]);

// Handle keyboard input for submit/cancel
useInput((input, key) => {
if (renaming) return; // Ignore input while renaming

if (key.escape) {
onCancel();
return;
}

if (key.return) {
if (newName.trim() && newName !== repo?.name) {
handleRenameConfirm();
}
return;
}
});

// Handle the rename confirmation
const handleRenameConfirm = async () => {
if (!repo || renaming || !newName.trim() || newName === repo.name) return;

try {
setRenaming(true);
setRenameError(null);
await onRename(repo, newName.trim());
} catch (e: any) {
setRenameError(e.message || 'Failed to rename repository');
setRenaming(false);
}
};

// Validate GitHub repository name (alphanumeric, hyphens, underscores, periods)
const handleNameChange = (value: string) => {
// GitHub repo names allow: alphanumeric, hyphen, underscore, period
// Filter out invalid characters
const filtered = value.replace(/[^a-zA-Z0-9\-_.]/g, '');
setNewName(filtered);
};

if (!repo) return null;

const owner = repo.nameWithOwner.split('/')[0];
const isDisabled = !newName.trim() || newName === repo.name;

return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="cyan"
paddingX={3}
paddingY={2}
width={80}
>
<Text bold color="cyan">Rename Repository</Text>
<Box height={1}><Text> </Text></Box>

<Text color="gray">Current: {repo.nameWithOwner}</Text>
<Box height={1}><Text> </Text></Box>

<Text>New name:</Text>
<Box flexDirection="row" alignItems="center">
<Text>{owner}/</Text>
<TextInput
value={newName}
onChange={handleNameChange}
placeholder={repo.name}
focus={!renaming}
/>
</Box>

{renaming ? (
<Box marginTop={2} justifyContent="center">
<Box flexDirection="row">
<Box marginRight={1}>
<SlowSpinner />
</Box>
<Text color="cyan">Renaming repository...</Text>
</Box>
</Box>
) : (
<>
<Box marginTop={2}>
<Text color="gray">
{isDisabled ?
'Enter a different name to rename' :
`Press Enter to rename to "${newName}"`
}
</Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Press Esc to cancel</Text>
</Box>
</>
)}

{renameError && (
<Box marginTop={1}>
<Text color="red">{renameError}</Text>
</Box>
)}
</Box>
);
}
1 change: 1 addition & 0 deletions src/ui/components/modals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export { default as LogoutModal } from './LogoutModal';
export { default as VisibilityModal } from './VisibilityModal';
export { default as SortModal } from './SortModal';
export { ChangeVisibilityModal } from './ChangeVisibilityModal';
export { default as RenameModal } from './RenameModal';

Loading