mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Add braces to mobile control flow (#4351)
This commit is contained in:
@@ -52,7 +52,9 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) handleUrl(url)
|
||||
if (url) {
|
||||
handleUrl(url)
|
||||
}
|
||||
})
|
||||
|
||||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||||
|
||||
@@ -39,10 +39,14 @@ export default function AccountsScreen() {
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void loadHosts().then((hosts) => {
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
setError('Host not found')
|
||||
@@ -60,9 +64,13 @@ export default function AccountsScreen() {
|
||||
// when the user switches accounts. Falls back to a one-shot accounts.list
|
||||
// if the subscription stream errors.
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') return
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setSnapshot(evt.snapshot)
|
||||
@@ -73,7 +81,9 @@ export default function AccountsScreen() {
|
||||
}, [client, connState])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await client.sendRequest('accounts.list')
|
||||
@@ -92,7 +102,9 @@ export default function AccountsScreen() {
|
||||
|
||||
const selectAccount = useCallback(
|
||||
async (provider: ProviderKey, accountId: string | null) => {
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
setBusyAccountId(accountId ?? `${provider}:default`)
|
||||
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
|
||||
try {
|
||||
@@ -115,7 +127,9 @@ export default function AccountsScreen() {
|
||||
)
|
||||
|
||||
const renderProviderSection = (provider: ProviderKey, title: string) => {
|
||||
if (!snapshot) return null
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
|
||||
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
|
||||
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
|
||||
|
||||
@@ -175,7 +175,9 @@ export default function MobileFileExplorerScreen() {
|
||||
|
||||
const openFile = useCallback(
|
||||
async (relativePath: string, kind: 'text' | 'binary') => {
|
||||
if (!client || kind === 'binary') return
|
||||
if (!client || kind === 'binary') {
|
||||
return
|
||||
}
|
||||
setOpeningPath(relativePath)
|
||||
try {
|
||||
const response = await client.sendRequest('files.open', {
|
||||
|
||||
+150
-53
@@ -130,8 +130,12 @@ const GROUP_OPTIONS: PickerOption<GroupMode>[] = [
|
||||
]
|
||||
|
||||
function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
|
||||
if (w.status) return w.status
|
||||
if (w.liveTerminalCount > 0) return 'active'
|
||||
if (w.status) {
|
||||
return w.status
|
||||
}
|
||||
if (w.liveTerminalCount > 0) {
|
||||
return 'active'
|
||||
}
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
@@ -139,9 +143,15 @@ function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | '
|
||||
// worktrees with idle terminal prompts had no recent output and were excluded.
|
||||
// Any worktree with live terminals or unread output counts as "active".
|
||||
function isWorktreeActive(w: Worktree): boolean {
|
||||
if (w.unread) return true
|
||||
if (w.status) return w.status !== 'inactive'
|
||||
if (w.liveTerminalCount > 0) return true
|
||||
if (w.unread) {
|
||||
return true
|
||||
}
|
||||
if (w.status) {
|
||||
return w.status !== 'inactive'
|
||||
}
|
||||
if (w.liveTerminalCount > 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -163,21 +173,29 @@ const WORKSPACE_STATUS_ORDER: ReturnType<typeof getWorktreeStatus>[] = [
|
||||
|
||||
function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] {
|
||||
return [...worktrees].sort((a, b) => {
|
||||
if (mode === 'name') return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
|
||||
if (mode === 'recent') return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
|
||||
if (mode === 'name') {
|
||||
return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
|
||||
}
|
||||
if (mode === 'recent') {
|
||||
return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
|
||||
}
|
||||
if (mode === 'repo') {
|
||||
const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
|
||||
return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
|
||||
}
|
||||
// 'smart' — attention-first
|
||||
if (a.unread !== b.unread) return a.unread ? -1 : 1
|
||||
if (a.unread !== b.unread) {
|
||||
return a.unread ? -1 : 1
|
||||
}
|
||||
const aStatus = getWorktreeStatus(a)
|
||||
const bStatus = getWorktreeStatus(b)
|
||||
const statusOrder = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 }
|
||||
if (statusOrder[aStatus] !== statusOrder[bStatus])
|
||||
if (statusOrder[aStatus] !== statusOrder[bStatus]) {
|
||||
return statusOrder[aStatus] - statusOrder[bStatus]
|
||||
if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0))
|
||||
}
|
||||
if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0)) {
|
||||
return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
|
||||
}
|
||||
return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
|
||||
})
|
||||
}
|
||||
@@ -218,11 +236,19 @@ const PR_GROUP_LABELS: Record<PRGroupKey, string> = {
|
||||
const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
|
||||
|
||||
function getPRGroupKey(w: Worktree): PRGroupKey {
|
||||
if (!w.linkedPR) return 'in-progress'
|
||||
if (!w.linkedPR) {
|
||||
return 'in-progress'
|
||||
}
|
||||
const s = w.linkedPR.state.toLowerCase()
|
||||
if (s === 'merged') return 'done'
|
||||
if (s === 'closed') return 'closed'
|
||||
if (s === 'draft') return 'in-progress'
|
||||
if (s === 'merged') {
|
||||
return 'done'
|
||||
}
|
||||
if (s === 'closed') {
|
||||
return 'closed'
|
||||
}
|
||||
if (s === 'draft') {
|
||||
return 'in-progress'
|
||||
}
|
||||
return 'in-review'
|
||||
}
|
||||
|
||||
@@ -265,8 +291,11 @@ function buildSections(
|
||||
for (const w of unpinned) {
|
||||
const key = w.repo || 'Unknown'
|
||||
const list = byRepo.get(key)
|
||||
if (list) list.push(w)
|
||||
else byRepo.set(key, [w])
|
||||
if (list) {
|
||||
list.push(w)
|
||||
} else {
|
||||
byRepo.set(key, [w])
|
||||
}
|
||||
}
|
||||
for (const [repo, items] of byRepo) {
|
||||
sections.push({ title: repo, data: items })
|
||||
@@ -276,8 +305,11 @@ function buildSections(
|
||||
for (const w of unpinned) {
|
||||
const key = getWorktreeStatus(w)
|
||||
const list = byStatus.get(key)
|
||||
if (list) list.push(w)
|
||||
else byStatus.set(key, [w])
|
||||
if (list) {
|
||||
list.push(w)
|
||||
} else {
|
||||
byStatus.set(key, [w])
|
||||
}
|
||||
}
|
||||
for (const status of WORKSPACE_STATUS_ORDER) {
|
||||
const items = byStatus.get(status)
|
||||
@@ -290,8 +322,11 @@ function buildSections(
|
||||
for (const w of unpinned) {
|
||||
const key = getPRGroupKey(w)
|
||||
const list = byGroup.get(key)
|
||||
if (list) list.push(w)
|
||||
else byGroup.set(key, [w])
|
||||
if (list) {
|
||||
list.push(w)
|
||||
} else {
|
||||
byGroup.set(key, [w])
|
||||
}
|
||||
}
|
||||
for (const groupKey of PR_GROUP_ORDER) {
|
||||
const items = byGroup.get(groupKey)
|
||||
@@ -368,11 +403,15 @@ export default function HostScreen() {
|
||||
|
||||
// Load persisted pins and preferences
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void (async () => {
|
||||
const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)])
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setPinnedIds(pins)
|
||||
setSortMode(prefs.sortMode as SortMode)
|
||||
setFilters({
|
||||
@@ -413,10 +452,14 @@ export default function HostScreen() {
|
||||
setWorktrees([])
|
||||
setLastKnownWorktrees([])
|
||||
}
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void loadHosts().then((hosts) => {
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
setError('Host not found')
|
||||
@@ -431,13 +474,17 @@ export default function HostScreen() {
|
||||
}, [hostId])
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') return
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
const requestClient = client
|
||||
const requestHostId = hostId
|
||||
|
||||
try {
|
||||
const response = await requestClient.sendRequest('worktree.ps')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) return
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
|
||||
setWorktrees(result.worktrees)
|
||||
@@ -447,8 +494,12 @@ export default function HostScreen() {
|
||||
void requestClient
|
||||
.sendRequest('repo.list')
|
||||
.then((repoResponse) => {
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) return
|
||||
if (!repoResponse.ok) return
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId) {
|
||||
return
|
||||
}
|
||||
if (!repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
@@ -486,7 +537,9 @@ export default function HostScreen() {
|
||||
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
|
||||
return prev
|
||||
}
|
||||
if (hostId) void savePinnedIds(hostId, serverPinned)
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, serverPinned)
|
||||
}
|
||||
return serverPinned
|
||||
})
|
||||
}
|
||||
@@ -502,14 +555,20 @@ export default function HostScreen() {
|
||||
// Today's compat constants are wide-open so this never blocks; the wire
|
||||
// format is in place to flip a switch in a future release.
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) return
|
||||
if (connState !== 'connected' || !client) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const requestClient = client
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await requestClient.sendRequest('status.get')
|
||||
if (cancelled || clientRef.current !== requestClient) return
|
||||
if (!response.ok) return
|
||||
if (cancelled || clientRef.current !== requestClient) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus
|
||||
const verdict = evaluateCompat({
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
@@ -538,7 +597,9 @@ export default function HostScreen() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (connState !== 'connected') return
|
||||
if (connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
// Why: React Navigation keeps previous stack screens mounted; only
|
||||
// poll the host list while this route is visible.
|
||||
@@ -553,9 +614,14 @@ export default function HostScreen() {
|
||||
(worktreeId: string, pinned: boolean) => {
|
||||
setPinnedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (pinned) next.add(worktreeId)
|
||||
else next.delete(worktreeId)
|
||||
if (hostId) void savePinnedIds(hostId, next)
|
||||
if (pinned) {
|
||||
next.add(worktreeId)
|
||||
} else {
|
||||
next.delete(worktreeId)
|
||||
}
|
||||
if (hostId) {
|
||||
void savePinnedIds(hostId, next)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
@@ -593,7 +659,9 @@ export default function HostScreen() {
|
||||
|
||||
const handleDeleteWorktree = useCallback(
|
||||
async (item: Worktree) => {
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const removeFromList = (list: Worktree[]) =>
|
||||
list.filter((w) => w.worktreeId !== item.worktreeId)
|
||||
@@ -619,7 +687,9 @@ export default function HostScreen() {
|
||||
)
|
||||
|
||||
const handleRemoveHost = useCallback(async () => {
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
// Why: close the shared client first so its WebSocket is gone before
|
||||
// the host record disappears; otherwise the next loadHosts() the
|
||||
// provider does (e.g. on remount) wouldn't find this host but the
|
||||
@@ -648,7 +718,9 @@ export default function HostScreen() {
|
||||
const handleSortChange = useCallback(
|
||||
(value: SortMode) => {
|
||||
setSortMode(value)
|
||||
if (hostId) void savePreferences(hostId, { sortMode: value })
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, { sortMode: value })
|
||||
}
|
||||
},
|
||||
[hostId]
|
||||
)
|
||||
@@ -656,10 +728,11 @@ export default function HostScreen() {
|
||||
const toggleActiveFilter = useCallback(() => {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev, activeOnly: !prev.activeOnly }
|
||||
if (hostId)
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, {
|
||||
filterMode: next.activeOnly ? 'active' : 'all'
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [hostId])
|
||||
@@ -668,10 +741,15 @@ export default function HostScreen() {
|
||||
(repo: string) => {
|
||||
setFilters((prev) => {
|
||||
const next = new Set(prev.selectedRepos)
|
||||
if (next.has(repo)) next.delete(repo)
|
||||
else next.add(repo)
|
||||
if (next.has(repo)) {
|
||||
next.delete(repo)
|
||||
} else {
|
||||
next.add(repo)
|
||||
}
|
||||
const updated = { ...prev, selectedRepos: next }
|
||||
if (hostId) void savePreferences(hostId, { selectedRepos: [...next] })
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, { selectedRepos: [...next] })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
},
|
||||
@@ -680,12 +758,16 @@ export default function HostScreen() {
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({ activeOnly: false, selectedRepos: new Set() })
|
||||
if (hostId) void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] })
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] })
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0
|
||||
if (filters.activeOnly) count++
|
||||
if (filters.activeOnly) {
|
||||
count++
|
||||
}
|
||||
count += filters.selectedRepos.size
|
||||
return count
|
||||
}, [filters])
|
||||
@@ -693,7 +775,9 @@ export default function HostScreen() {
|
||||
const handleGroupChange = useCallback(
|
||||
(value: GroupMode) => {
|
||||
setGroupMode(value)
|
||||
if (hostId) void savePreferences(hostId, { groupMode: value })
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, { groupMode: value })
|
||||
}
|
||||
},
|
||||
[hostId]
|
||||
)
|
||||
@@ -716,7 +800,9 @@ export default function HostScreen() {
|
||||
const uniqueRepos = useMemo(() => {
|
||||
const repos = new Map<string, string>()
|
||||
for (const w of displayWorktrees) {
|
||||
if (!repos.has(w.repo)) repos.set(w.repo, repoColorsByName.get(w.repo) ?? repoColor(w.repo))
|
||||
if (!repos.has(w.repo)) {
|
||||
repos.set(w.repo, repoColorsByName.get(w.repo) ?? repoColor(w.repo))
|
||||
}
|
||||
}
|
||||
return [...repos.entries()].map(([name, color]) => ({ name, color }))
|
||||
}, [displayWorktrees, repoColorsByName])
|
||||
@@ -730,9 +816,14 @@ export default function HostScreen() {
|
||||
(title: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) next.delete(title)
|
||||
else next.add(title)
|
||||
if (hostId) void savePreferences(hostId, { collapsedGroups: [...next] })
|
||||
if (next.has(title)) {
|
||||
next.delete(title)
|
||||
} else {
|
||||
next.add(title)
|
||||
}
|
||||
if (hostId) {
|
||||
void savePreferences(hostId, { collapsedGroups: [...next] })
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
@@ -799,7 +890,9 @@ export default function HostScreen() {
|
||||
const verdict = headerVerdict
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
|
||||
if (!showReconnectButton) return null
|
||||
if (!showReconnectButton) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
@@ -979,7 +1072,9 @@ export default function HostScreen() {
|
||||
isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
|
||||
]}
|
||||
renderSectionHeader={({ section }) => {
|
||||
if (!section.title) return null
|
||||
if (!section.title) {
|
||||
return null
|
||||
}
|
||||
const isCollapsed = collapsedGroups.has(section.title)
|
||||
const rawSection = rawSections.find((s) => s.title === section.title)
|
||||
const count = rawSection?.data.length ?? 0
|
||||
@@ -1272,7 +1367,9 @@ function ListSeparator() {
|
||||
function repoColor(name: string): string {
|
||||
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
}
|
||||
return palette[Math.abs(hash) % palette.length]!
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -236,8 +236,12 @@ function formatBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null
|
||||
}
|
||||
|
||||
function diffLinePrefix(kind: MobileDiffLine['kind']): string {
|
||||
if (kind === 'add') return '+'
|
||||
if (kind === 'delete') return '-'
|
||||
if (kind === 'add') {
|
||||
return '+'
|
||||
}
|
||||
if (kind === 'delete') {
|
||||
return '-'
|
||||
}
|
||||
return ' '
|
||||
}
|
||||
|
||||
@@ -336,7 +340,9 @@ export default function MobileSourceControlScreen() {
|
||||
setBranchCompareState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
|
||||
try {
|
||||
const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
if (!baseRef) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
return true
|
||||
@@ -345,7 +351,9 @@ export default function MobileSourceControlScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
baseRef
|
||||
})
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
@@ -359,7 +367,9 @@ export default function MobileSourceControlScreen() {
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load committed changes'
|
||||
setBranchCompareState((prev) => {
|
||||
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
|
||||
@@ -404,14 +414,18 @@ export default function MobileSourceControlScreen() {
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
|
||||
try {
|
||||
for (let attempt = 0; attempt <= SELECTOR_RETRY_COUNT; attempt += 1) {
|
||||
const response = await client.sendRequest('git.status', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as MobileGitStatusResult
|
||||
setScreenState({ kind: 'ready', status: result })
|
||||
@@ -433,13 +447,17 @@ export default function MobileSourceControlScreen() {
|
||||
isMobileGitTransientRefreshError(response.error?.code, response.error?.message)
|
||||
if (shouldRetry && attempt < SELECTOR_RETRY_COUNT) {
|
||||
await wait(SELECTOR_RETRY_DELAY_MS)
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new Error(response.error?.message || 'Unable to load source control')
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentLoad()) return false
|
||||
if (!isCurrentLoad()) {
|
||||
return false
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load source control'
|
||||
setScreenState((prev) => {
|
||||
// Why: git mutations can succeed while the immediate status refresh
|
||||
@@ -590,13 +608,17 @@ export default function MobileSourceControlScreen() {
|
||||
runner: () => Promise<void>,
|
||||
options?: { clearCommitMessage?: boolean }
|
||||
) => {
|
||||
if (busyActionRef.current) return false
|
||||
if (busyActionRef.current) {
|
||||
return false
|
||||
}
|
||||
busyActionRef.current = actionId
|
||||
setBusyAction(actionId)
|
||||
setActionError(null)
|
||||
try {
|
||||
await runner()
|
||||
if (!mountedRef.current) return false
|
||||
if (!mountedRef.current) {
|
||||
return false
|
||||
}
|
||||
if (options?.clearCommitMessage) {
|
||||
setCommitMessage('')
|
||||
}
|
||||
@@ -604,7 +626,9 @@ export default function MobileSourceControlScreen() {
|
||||
await loadStatus({ preserveReadyOnFailure: true, force: true })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return false
|
||||
if (!mountedRef.current) {
|
||||
return false
|
||||
}
|
||||
triggerError()
|
||||
setActionError(err instanceof Error ? err.message : 'Source control action failed')
|
||||
return false
|
||||
@@ -655,19 +679,25 @@ export default function MobileSourceControlScreen() {
|
||||
|
||||
const stageAll = useCallback(async () => {
|
||||
const filePaths = stageablePaths
|
||||
if (filePaths.length === 0) return
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
await runGitAction('stage-all', 'git.bulkStage', { filePaths })
|
||||
}, [runGitAction, stageablePaths])
|
||||
|
||||
const unstageAll = useCallback(async () => {
|
||||
const filePaths = unstageablePaths
|
||||
if (filePaths.length === 0) return
|
||||
if (filePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
await runGitAction('unstage-all', 'git.bulkUnstage', { filePaths })
|
||||
}, [runGitAction, unstageablePaths])
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
const message = commitMessage.trim()
|
||||
if (!message) return false
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
return await runGitWorkflow(
|
||||
'commit',
|
||||
async () => {
|
||||
@@ -680,8 +710,12 @@ export default function MobileSourceControlScreen() {
|
||||
const runCommitFollowUps = useCallback(
|
||||
async (actionId: string, afterCommit: () => Promise<void>) => {
|
||||
const message = commitMessage.trim()
|
||||
if (!message) return false
|
||||
if (busyActionRef.current) return false
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
if (busyActionRef.current) {
|
||||
return false
|
||||
}
|
||||
busyActionRef.current = actionId
|
||||
setBusyAction(actionId)
|
||||
setActionError(null)
|
||||
@@ -690,13 +724,17 @@ export default function MobileSourceControlScreen() {
|
||||
await sendCommitRequest(message)
|
||||
didCommit = true
|
||||
await afterCommit()
|
||||
if (!mountedRef.current) return false
|
||||
if (!mountedRef.current) {
|
||||
return false
|
||||
}
|
||||
setCommitMessage('')
|
||||
triggerSuccess()
|
||||
await loadStatus({ preserveReadyOnFailure: true, force: true })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return false
|
||||
if (!mountedRef.current) {
|
||||
return false
|
||||
}
|
||||
triggerError()
|
||||
const message = err instanceof Error ? err.message : 'Source control action failed'
|
||||
if (didCommit) {
|
||||
@@ -778,10 +816,16 @@ export default function MobileSourceControlScreen() {
|
||||
|
||||
const openFile = useCallback(
|
||||
async (entry: MobileGitStatusEntry) => {
|
||||
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') return
|
||||
if (openingPathRef.current || busyActionRef.current) return
|
||||
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') {
|
||||
return
|
||||
}
|
||||
if (openingPathRef.current || busyActionRef.current) {
|
||||
return
|
||||
}
|
||||
if (!client || connState !== 'connected') {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setActionError('Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
@@ -803,7 +847,9 @@ export default function MobileSourceControlScreen() {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to open diff')
|
||||
}
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
triggerSelection()
|
||||
if (origin === 'session') {
|
||||
router.back()
|
||||
@@ -818,7 +864,9 @@ export default function MobileSourceControlScreen() {
|
||||
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${query ? `?${query}` : ''}`
|
||||
)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
triggerError()
|
||||
setActionError(err instanceof Error ? err.message : 'Unable to open diff')
|
||||
} finally {
|
||||
@@ -835,9 +883,13 @@ export default function MobileSourceControlScreen() {
|
||||
|
||||
const openBranchDiff = useCallback(
|
||||
async (entry: MobileGitBranchChangeEntry) => {
|
||||
if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) return
|
||||
if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) {
|
||||
return
|
||||
}
|
||||
if (!client || connState !== 'connected') {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setActionError('Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
@@ -872,7 +924,9 @@ export default function MobileSourceControlScreen() {
|
||||
throw new Error('Binary branch diff preview unavailable on mobile')
|
||||
}
|
||||
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setBranchDiffPreview({
|
||||
kind: 'ready',
|
||||
entry,
|
||||
@@ -882,7 +936,9 @@ export default function MobileSourceControlScreen() {
|
||||
})
|
||||
triggerSelection()
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
triggerError()
|
||||
setBranchDiffPreview({
|
||||
kind: 'error',
|
||||
|
||||
+771
-258
File diff suppressed because it is too large
Load Diff
+81
-27
@@ -105,9 +105,13 @@ function formatDuration(ms: number): string {
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const days = Math.floor(totalHours / 24)
|
||||
const hours = totalHours % 24
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
const minutes = totalMinutes % 60
|
||||
if (totalHours > 0) return `${totalHours}h ${minutes}m`
|
||||
if (totalHours > 0) {
|
||||
return `${totalHours}h ${minutes}m`
|
||||
}
|
||||
return `${totalMinutes}m`
|
||||
}
|
||||
|
||||
@@ -134,7 +138,9 @@ function fetchStats(
|
||||
client
|
||||
.sendRequest('stats.summary')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
setStats(response.result as StatsSummary)
|
||||
}
|
||||
@@ -157,7 +163,9 @@ function fetchWorktreeInfo(
|
||||
// momentarily flip to "0 worktrees" / disappear during reconnects.
|
||||
const markLoadedIfMissing = () => {
|
||||
setInfo((prev) => {
|
||||
if (prev[hostId]) return prev
|
||||
if (prev[hostId]) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[hostId]: {
|
||||
@@ -173,7 +181,9 @@ function fetchWorktreeInfo(
|
||||
client
|
||||
.sendRequest('worktree.ps')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = response.result as { worktrees: WorktreeSummary[] }
|
||||
const worktrees = result.worktrees ?? []
|
||||
@@ -195,7 +205,9 @@ function fetchWorktreeInfo(
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) markLoadedIfMissing()
|
||||
if (!disposed()) {
|
||||
markLoadedIfMissing()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,7 +222,9 @@ function fetchAccountsSnapshot(
|
||||
client
|
||||
.sendRequest('accounts.list')
|
||||
.then((response) => {
|
||||
if (disposed()) return
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const snapshot = response.result as AccountsSnapshot
|
||||
setSnapshots((prev) => ({ ...prev, [hostId]: snapshot }))
|
||||
@@ -233,7 +247,9 @@ function fetchTaskProviders(
|
||||
client.sendRequest('linear.status')
|
||||
])
|
||||
.then(([settingsResponse, preflightResponse, linearResponse]) => {
|
||||
if (disposed()) return
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
const settings = settingsResponse.ok
|
||||
? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
|
||||
{}) as HomeTaskSettings)
|
||||
@@ -252,7 +268,9 @@ function fetchTaskProviders(
|
||||
setProviders((prev) => ({ ...prev, [hostId]: providers }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed()) return
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] }))
|
||||
})
|
||||
}
|
||||
@@ -302,7 +320,9 @@ export default function HomeScreen() {
|
||||
// openEntry on cold start (which serialised behind the first one and
|
||||
// showed up as multi-second connect latency).
|
||||
useEffect(() => {
|
||||
if (hosts.length > 0) primeHosts(hosts)
|
||||
if (hosts.length > 0) {
|
||||
primeHosts(hosts)
|
||||
}
|
||||
}, [hosts, primeHosts])
|
||||
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
|
||||
// Why: the focus callback stays stable to avoid refetching on every
|
||||
@@ -318,11 +338,15 @@ export default function HomeScreen() {
|
||||
// Stream/list responses overwrite this seed in place when they arrive.
|
||||
const hydratedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (hydratedRef.current) return
|
||||
if (hydratedRef.current) {
|
||||
return
|
||||
}
|
||||
hydratedRef.current = true
|
||||
let cancelled = false
|
||||
void loadHomeSnapshot().then((snap) => {
|
||||
if (cancelled || !snap) return
|
||||
if (cancelled || !snap) {
|
||||
return
|
||||
}
|
||||
setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo))
|
||||
setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost))
|
||||
for (const [hostId, info] of Object.entries(snap.worktreeInfo)) {
|
||||
@@ -357,10 +381,14 @@ export default function HomeScreen() {
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then((h) => {
|
||||
if (!stale) setHosts(h)
|
||||
if (!stale) {
|
||||
setHosts(h)
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
if (stale || !raw) return
|
||||
if (stale || !raw) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLastVisited(JSON.parse(raw))
|
||||
} catch {}
|
||||
@@ -427,7 +455,9 @@ export default function HomeScreen() {
|
||||
// already tracked — otherwise the initial-acquire frame (entry not
|
||||
// yet materialised) would briefly flip every host to 'disconnected'.
|
||||
for (const host of hosts) {
|
||||
if (liveIds.has(host.id)) continue
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
}
|
||||
if (!host.publicKeyB64 || !host.deviceToken) {
|
||||
if (next[host.id] !== 'auth-failed') {
|
||||
next[host.id] = 'auth-failed'
|
||||
@@ -470,7 +500,9 @@ export default function HomeScreen() {
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! }))
|
||||
@@ -503,7 +535,9 @@ export default function HomeScreen() {
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
for (const c of cleanups) c()
|
||||
for (const c of cleanups) {
|
||||
c()
|
||||
}
|
||||
}
|
||||
// Why: depend on the host-id set AND each entry's client identity, so
|
||||
// resubscriptions don't fire on every render that produces a new
|
||||
@@ -538,10 +572,14 @@ export default function HomeScreen() {
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) return { hostId: lastVisited.hostId, worktree: match }
|
||||
if (match) {
|
||||
return { hostId: lastVisited.hostId, worktree: match }
|
||||
}
|
||||
}
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
if (hostStates[host.id] !== 'connected') {
|
||||
continue
|
||||
}
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
@@ -556,12 +594,18 @@ export default function HomeScreen() {
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
if (hostStates[host.id] !== 'connected') {
|
||||
continue
|
||||
}
|
||||
const snap = accountsByHost[host.id]
|
||||
if (!snap) continue
|
||||
if (!snap) {
|
||||
continue
|
||||
}
|
||||
const hasClaude = snap.claude.accounts.length > 0
|
||||
const hasCodex = snap.codex.accounts.length > 0
|
||||
if (hasClaude || hasCodex) items.push({ host, snapshot: snap })
|
||||
if (hasClaude || hasCodex) {
|
||||
items.push({ host, snapshot: snap })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [sortedHosts, hostStates, accountsByHost])
|
||||
@@ -575,7 +619,9 @@ export default function HomeScreen() {
|
||||
: []
|
||||
const openTasks = useCallback(
|
||||
(provider?: TaskProvider) => {
|
||||
if (!primaryConnectedHost) return
|
||||
if (!primaryConnectedHost) {
|
||||
return
|
||||
}
|
||||
const suffix = provider ? `?taskSource=${provider}` : ''
|
||||
router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`)
|
||||
},
|
||||
@@ -636,7 +682,9 @@ export default function HomeScreen() {
|
||||
)
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) return
|
||||
if (!renameTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renameHost(renameTarget.id, newName)
|
||||
setRenameTarget(null)
|
||||
@@ -647,7 +695,9 @@ export default function HomeScreen() {
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirmRemove) return
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: close the shared client first so the WebSocket is gone
|
||||
// before the host record disappears from loadHosts().
|
||||
@@ -921,7 +971,9 @@ export default function HomeScreen() {
|
||||
provider === 'claude'
|
||||
? snapshot.claude.accounts
|
||||
: snapshot.codex.accounts
|
||||
if (accounts.length === 0) return null
|
||||
if (accounts.length === 0) {
|
||||
return null
|
||||
}
|
||||
const limits = getActiveProviderRateLimits(snapshot, provider)
|
||||
const isFetching =
|
||||
limits?.status === 'fetching' || limits?.status === 'idle'
|
||||
@@ -977,7 +1029,9 @@ export default function HomeScreen() {
|
||||
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
|
||||
actions={(() => {
|
||||
const host = actionTarget
|
||||
if (!host) return []
|
||||
if (!host) {
|
||||
return []
|
||||
}
|
||||
const state = hostStates[host.id] ?? 'connecting'
|
||||
const isLive =
|
||||
state === 'connected' ||
|
||||
|
||||
@@ -73,7 +73,9 @@ export default function PairConfirmScreen() {
|
||||
}, [])
|
||||
|
||||
async function confirm() {
|
||||
if (!offer) return
|
||||
if (!offer) {
|
||||
return
|
||||
}
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
@@ -92,7 +94,9 @@ export default function PairConfirmScreen() {
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
}
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
@@ -103,7 +107,9 @@ export default function PairConfirmScreen() {
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
activePairingAttemptRef.current = null
|
||||
}
|
||||
if (!mountedRef.current || !attemptIsCurrent) return
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -111,7 +117,9 @@ export default function PairConfirmScreen() {
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
activePairingAttemptRef.current = null
|
||||
}
|
||||
if (!mountedRef.current || !attemptIsCurrent) return
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
@@ -123,7 +131,9 @@ export default function PairConfirmScreen() {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
@@ -144,10 +154,14 @@ export default function PairConfirmScreen() {
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
|
||||
@@ -59,7 +59,9 @@ export default function PairScanScreen() {
|
||||
|
||||
const handleBarCodeScanned = useCallback(
|
||||
({ data }: { data: string }) => {
|
||||
if (processingRef.current) return
|
||||
if (processingRef.current) {
|
||||
return
|
||||
}
|
||||
processingRef.current = true
|
||||
|
||||
const offer = decodePairingUrl(data)
|
||||
@@ -77,7 +79,9 @@ export default function PairScanScreen() {
|
||||
|
||||
const handlePasteSubmit = useCallback((input: string) => {
|
||||
setPasteVisible(false)
|
||||
if (processingRef.current) return
|
||||
if (processingRef.current) {
|
||||
return
|
||||
}
|
||||
processingRef.current = true
|
||||
|
||||
const offer = parsePairingCode(input)
|
||||
@@ -111,7 +115,9 @@ export default function PairScanScreen() {
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
}
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
@@ -122,7 +128,9 @@ export default function PairScanScreen() {
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
activePairingAttemptRef.current = null
|
||||
}
|
||||
if (!mountedRef.current || !attemptIsCurrent) return
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -130,7 +138,9 @@ export default function PairScanScreen() {
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
activePairingAttemptRef.current = null
|
||||
}
|
||||
if (!mountedRef.current || !attemptIsCurrent) return
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
@@ -143,7 +153,9 @@ export default function PairScanScreen() {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
@@ -167,10 +179,14 @@ export default function PairScanScreen() {
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
|
||||
@@ -47,9 +47,13 @@ const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | nul
|
||||
]
|
||||
|
||||
function valueFromMs(ms: number | null | undefined): RestoreValue {
|
||||
if (ms == null) return 'indefinite'
|
||||
if (ms == null) {
|
||||
return 'indefinite'
|
||||
}
|
||||
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
|
||||
if (exact) return exact.value
|
||||
if (exact) {
|
||||
return exact.value
|
||||
}
|
||||
// Why: server may return a non-preset ms (custom value, future preset,
|
||||
// or server-side clamp). Snap to the closest finite preset so the
|
||||
// picker's selected radio agrees with the row sublabel rendered by
|
||||
@@ -57,7 +61,9 @@ function valueFromMs(ms: number | null | undefined): RestoreValue {
|
||||
let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
|
||||
let bestDelta = Infinity
|
||||
for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
|
||||
if (opt.ms == null) continue
|
||||
if (opt.ms == null) {
|
||||
continue
|
||||
}
|
||||
const delta = Math.abs(opt.ms - ms)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
@@ -68,8 +74,12 @@ function valueFromMs(ms: number | null | undefined): RestoreValue {
|
||||
}
|
||||
|
||||
function autoRestoreSummary(ms: number | null | undefined): string {
|
||||
if (ms === undefined) return '…'
|
||||
if (ms === null) return AUTO_RESTORE_FIT_OPTIONS[0]!.label
|
||||
if (ms === undefined) {
|
||||
return '…'
|
||||
}
|
||||
if (ms === null) {
|
||||
return AUTO_RESTORE_FIT_OPTIONS[0]!.label
|
||||
}
|
||||
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
|
||||
return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
|
||||
}
|
||||
@@ -174,7 +184,9 @@ export default function TerminalSettingsScreen() {
|
||||
const refreshShortcutLayout = useCallback(() => {
|
||||
const refreshSeq = layoutWriteSeqRef.current
|
||||
void loadTerminalAccessoryLayout().then((layout) => {
|
||||
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return
|
||||
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
|
||||
return
|
||||
}
|
||||
setVisibleBuiltInIds(layout.visibleBuiltInIds)
|
||||
})
|
||||
}, [])
|
||||
@@ -230,11 +242,15 @@ export default function TerminalSettingsScreen() {
|
||||
let cancelled = false
|
||||
for (const host of hosts) {
|
||||
const client = hostClientsById.get(host.id) ?? null
|
||||
if (!client) continue
|
||||
if (!client) {
|
||||
continue
|
||||
}
|
||||
void client
|
||||
.sendRequest('terminal.getAutoRestoreFit')
|
||||
.then((resp) => {
|
||||
if (cancelled) return
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
const value = (resp as { ms?: number | null } | null)?.ms
|
||||
// Why: reconnect/status ticks can replay the same value; preserving
|
||||
// object identity avoids rerendering every settings row again.
|
||||
@@ -253,9 +269,13 @@ export default function TerminalSettingsScreen() {
|
||||
|
||||
async function selectValue(hostId: string, value: RestoreValue) {
|
||||
const client = hostClientsById.get(hostId) ?? null
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
|
||||
if (!opt) return
|
||||
if (!opt) {
|
||||
return
|
||||
}
|
||||
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms))
|
||||
try {
|
||||
const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
|
||||
@@ -397,7 +417,9 @@ export default function TerminalSettingsScreen() {
|
||||
options={AUTO_RESTORE_FIT_OPTIONS}
|
||||
selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
|
||||
onSelect={(v) => {
|
||||
if (pickerHost) void selectValue(pickerHost.id, v)
|
||||
if (pickerHost) {
|
||||
void selectValue(pickerHost.id, v)
|
||||
}
|
||||
}}
|
||||
onClose={() => setPickerHostId(null)}
|
||||
/>
|
||||
|
||||
@@ -160,7 +160,9 @@ export default function TroubleshootScreen() {
|
||||
results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' })
|
||||
}
|
||||
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
setChecks([...results])
|
||||
|
||||
const internetCheck = startDiagnosticFetchTimeout(5000)
|
||||
@@ -169,14 +171,18 @@ export default function TroubleshootScreen() {
|
||||
const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', {
|
||||
signal: internetCheck.signal
|
||||
})
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
results.push(
|
||||
resp.ok
|
||||
? { label: 'Internet', status: 'pass', detail: 'Connected' }
|
||||
: { label: 'Internet', status: 'warn', detail: 'Unexpected response' }
|
||||
)
|
||||
} catch {
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
results.push({ label: 'Internet', status: 'fail', detail: 'No connection' })
|
||||
} finally {
|
||||
internetCheck.dispose()
|
||||
@@ -185,15 +191,21 @@ export default function TroubleshootScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
setChecks([...results])
|
||||
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
for (const host of hosts) {
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
const reachable = await testHostReachability(host.endpoint)
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
results.push({
|
||||
label: host.name,
|
||||
status: reachable ? 'pass' : 'fail',
|
||||
@@ -207,7 +219,9 @@ export default function TroubleshootScreen() {
|
||||
results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' })
|
||||
}
|
||||
|
||||
if (!isCurrentRun()) return
|
||||
if (!isCurrentRun()) {
|
||||
return
|
||||
}
|
||||
|
||||
results.push({
|
||||
label: 'Platform',
|
||||
|
||||
@@ -36,11 +36,15 @@ function e2eeEncrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
|
||||
function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
const bundle = Uint8Array.from(Buffer.from(encrypted, 'base64'))
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) return null
|
||||
if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) {
|
||||
return null
|
||||
}
|
||||
const nonce = bundle.slice(0, nacl.box.nonceLength)
|
||||
const ciphertext = bundle.slice(nacl.box.nonceLength)
|
||||
const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey)
|
||||
if (!plaintext) return null
|
||||
if (!plaintext) {
|
||||
return null
|
||||
}
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
@@ -147,7 +151,9 @@ function toGitStatusEntry(entry: FakeGitEntry): MobileGitStatusEntry {
|
||||
}
|
||||
|
||||
function stageFakeGitEntry(entry: FakeGitEntry, filePaths: Set<string>): FakeGitEntry {
|
||||
if (!filePaths.has(entry.path)) return entry
|
||||
if (!filePaths.has(entry.path)) {
|
||||
return entry
|
||||
}
|
||||
if (entry.area === 'untracked') {
|
||||
return { ...entry, area: 'staged', status: 'added', stagedFromUntracked: true }
|
||||
}
|
||||
@@ -155,7 +161,9 @@ function stageFakeGitEntry(entry: FakeGitEntry, filePaths: Set<string>): FakeGit
|
||||
}
|
||||
|
||||
function unstageFakeGitEntry(entry: FakeGitEntry, filePaths: Set<string>): FakeGitEntry {
|
||||
if (!filePaths.has(entry.path)) return entry
|
||||
if (!filePaths.has(entry.path)) {
|
||||
return entry
|
||||
}
|
||||
if (entry.stagedFromUntracked) {
|
||||
return { ...entry, area: 'untracked', status: 'untracked', stagedFromUntracked: false }
|
||||
}
|
||||
|
||||
@@ -140,7 +140,9 @@ async function listHandles(
|
||||
async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<string> {
|
||||
const terminals = await listHandles(ws)
|
||||
const existing = terminals.find((terminal) => terminal.handle !== handleA)
|
||||
if (existing) return existing.handle
|
||||
if (existing) {
|
||||
return existing.handle
|
||||
}
|
||||
|
||||
const created = await send(ws, 'terminal.create', {
|
||||
worktree: worktreeSelector,
|
||||
@@ -174,7 +176,9 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
|
||||
}
|
||||
})
|
||||
streamListeners.set(id, (result) => {
|
||||
if (result.type !== 'scrollback') return
|
||||
if (result.type !== 'scrollback') {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
pending.delete(id)
|
||||
streamListeners.delete(id)
|
||||
@@ -333,7 +337,9 @@ ws.on('message', (data) => {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw)
|
||||
if (!plaintext) return
|
||||
if (!plaintext) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
const result = response.result
|
||||
@@ -344,7 +350,9 @@ ws.on('message', (data) => {
|
||||
}
|
||||
|
||||
const request = pending.get(response.id)
|
||||
if (!request || request.method === 'terminal.subscribe') return
|
||||
if (!request || request.method === 'terminal.subscribe') {
|
||||
return
|
||||
}
|
||||
pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
})
|
||||
|
||||
@@ -338,7 +338,9 @@ ws.on('message', (data) => {
|
||||
return
|
||||
}
|
||||
const plaintext = decrypt(raw)
|
||||
if (!plaintext) return
|
||||
if (!plaintext) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
const result = response.result
|
||||
@@ -349,7 +351,9 @@ ws.on('message', (data) => {
|
||||
}
|
||||
|
||||
const request = pending.get(response.id)
|
||||
if (!request || request.method === 'terminal.subscribe') return
|
||||
if (!request || request.method === 'terminal.subscribe') {
|
||||
return
|
||||
}
|
||||
pending.delete(response.id)
|
||||
request.resolve(response)
|
||||
})
|
||||
|
||||
@@ -114,7 +114,9 @@ function formatResponse(response: RpcResponse): string {
|
||||
}
|
||||
|
||||
async function chooseWorktree(ws: WebSocket): Promise<string> {
|
||||
if (worktreeSelector) return worktreeSelector
|
||||
if (worktreeSelector) {
|
||||
return worktreeSelector
|
||||
}
|
||||
|
||||
const response = await send(ws, 'worktree.ps')
|
||||
if (!response.ok) {
|
||||
@@ -275,7 +277,9 @@ ws.on('message', (data) => {
|
||||
// connect flow above. The global listener only cares about encrypted RPC.
|
||||
return
|
||||
}
|
||||
if (!plaintext) return
|
||||
if (!plaintext) {
|
||||
return
|
||||
}
|
||||
const response = JSON.parse(plaintext) as RpcResponse
|
||||
if (response._meta?.runtimeId) {
|
||||
runtimeId = response._meta.runtimeId
|
||||
|
||||
@@ -430,7 +430,9 @@ export function MobileBrowserPane({
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
let startupTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
if (streamGenerationRef.current !== generation) return
|
||||
if (streamGenerationRef.current !== generation) {
|
||||
return
|
||||
}
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setError('Browser stream timed out.')
|
||||
@@ -449,7 +451,9 @@ export function MobileBrowserPane({
|
||||
...streamRequest
|
||||
},
|
||||
(payload) => {
|
||||
if (streamGenerationRef.current !== generation) return
|
||||
if (streamGenerationRef.current !== generation) {
|
||||
return
|
||||
}
|
||||
const event = payload as {
|
||||
type?: string
|
||||
message?: string
|
||||
@@ -509,7 +513,9 @@ export function MobileBrowserPane({
|
||||
},
|
||||
{
|
||||
onBinaryFrame: (frame) => {
|
||||
if (streamGenerationRef.current !== generation) return
|
||||
if (streamGenerationRef.current !== generation) {
|
||||
return
|
||||
}
|
||||
clearStartupTimer()
|
||||
if (cacheKey) {
|
||||
applyFrameThrottled(frame, cacheKey)
|
||||
@@ -758,9 +764,13 @@ export function MobileBrowserPane({
|
||||
clearLongPressTimer()
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
const start = startPointRef.current
|
||||
if (!start) return
|
||||
if (!start) {
|
||||
return
|
||||
}
|
||||
const point = mapTouchPoint(start.x, start.y)
|
||||
if (!point) return
|
||||
if (!point) {
|
||||
return
|
||||
}
|
||||
rightClickSentRef.current = true
|
||||
void sendPointerClick(point, 'right')
|
||||
onToast('Right click')
|
||||
|
||||
+9
-3
@@ -34,10 +34,14 @@ let memoryCache: HomeSnapshot | null = null
|
||||
let writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
|
||||
if (memoryCache) return memoryCache
|
||||
if (memoryCache) {
|
||||
return memoryCache
|
||||
}
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw) as HomeSnapshot
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -58,7 +62,9 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
|
||||
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
|
||||
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
|
||||
memoryCache = snapshot
|
||||
if (writeTimer) clearTimeout(writeTimer)
|
||||
if (writeTimer) {
|
||||
clearTimeout(writeTimer)
|
||||
}
|
||||
writeTimer = setTimeout(() => {
|
||||
writeTimer = null
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
|
||||
|
||||
Vendored
+6
-2
@@ -20,13 +20,17 @@ export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
|
||||
cache.set(hostId, { worktrees, at: Date.now() })
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest) cache.delete(oldest)
|
||||
if (oldest) {
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWorktrees(hostId: string): unknown[] | null {
|
||||
const entry = cache.get(hostId)
|
||||
if (!entry) return null
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - entry.at > MAX_AGE_MS) {
|
||||
cache.delete(hostId)
|
||||
return null
|
||||
|
||||
@@ -23,8 +23,12 @@ type Props = {
|
||||
}
|
||||
|
||||
function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon): LucideIcon {
|
||||
if (icon) return icon
|
||||
if (destructive || /delete|remove/i.test(label)) return Trash2
|
||||
if (icon) {
|
||||
return icon
|
||||
}
|
||||
if (destructive || /delete|remove/i.test(label)) {
|
||||
return Trash2
|
||||
}
|
||||
return Edit3
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ export function BottomDrawer({
|
||||
|
||||
// Why: hidden drawers are rendered by parent screens even while closed; keep
|
||||
// their Reanimated/Gesture setup out of hot paths like commit-message typing.
|
||||
if (!resolvedMounted) return null
|
||||
if (!resolvedMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MountedBottomDrawer
|
||||
@@ -121,7 +123,9 @@ function MountedBottomDrawer({
|
||||
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
|
||||
// and give us the exact height to shift the drawer by.
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
|
||||
@@ -142,7 +146,9 @@ function MountedBottomDrawer({
|
||||
}, [visible, insets.bottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
onClose()
|
||||
@@ -215,7 +221,9 @@ function MountedBottomDrawer({
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) return
|
||||
if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
|
||||
return
|
||||
}
|
||||
|
||||
const translationY = e.translationY - contentDragStartY.value
|
||||
if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
|
||||
|
||||
@@ -28,15 +28,21 @@ function formatTime(ts: number, baseTs: number): string {
|
||||
// Why: show elapsed seconds since the first entry — absolute wall-clock
|
||||
// time isn't actionable when debugging "why is connecting stuck".
|
||||
const elapsed = Math.max(0, ts - baseTs) / 1000
|
||||
if (elapsed < 10) return `+${elapsed.toFixed(2)}s`
|
||||
if (elapsed < 100) return `+${elapsed.toFixed(1)}s`
|
||||
if (elapsed < 10) {
|
||||
return `+${elapsed.toFixed(2)}s`
|
||||
}
|
||||
if (elapsed < 100) {
|
||||
return `+${elapsed.toFixed(1)}s`
|
||||
}
|
||||
return `+${Math.round(elapsed)}s`
|
||||
}
|
||||
|
||||
export function ConnectionLog({ entries, title }: Props) {
|
||||
const scrollRef = useRef<ScrollView | null>(null)
|
||||
|
||||
if (entries.length === 0) return null
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
const baseTs = entries[0]!.ts
|
||||
|
||||
return (
|
||||
|
||||
@@ -119,7 +119,9 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
|
||||
const previewKeyLabel = useMemo(() => {
|
||||
const special = SPECIAL_KEY_BY_ID[shortcutKey]
|
||||
if (special) return special.label
|
||||
if (special) {
|
||||
return special.label
|
||||
}
|
||||
return shortcutKey.length === 1 ? shortcutKey.toUpperCase() : shortcutKey
|
||||
}, [shortcutKey])
|
||||
|
||||
@@ -156,14 +158,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
|
||||
const handleShortcutSave = useCallback(() => {
|
||||
const built = buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers })
|
||||
if (!built) return
|
||||
if (!built) {
|
||||
return
|
||||
}
|
||||
void addKey({ label: built.label, bytes: built.bytes, enter: false })
|
||||
}, [addKey, shortcutKey, shortcutModifiers])
|
||||
|
||||
const handleMacroSave = useCallback(() => {
|
||||
const label = macroLabel.trim() || macroText.trim().slice(0, 12)
|
||||
const text = macroText
|
||||
if (!label || !text) return
|
||||
if (!label || !text) {
|
||||
return
|
||||
}
|
||||
const bytes = macroEnter ? `${text}\r` : text
|
||||
void addKey({ label, bytes, enter: false })
|
||||
}, [addKey, macroLabel, macroText, macroEnter])
|
||||
@@ -324,7 +330,9 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
<View style={styles.keyGrid}>
|
||||
{group.ids.map((id) => {
|
||||
const key = SPECIAL_KEY_BY_ID[id]
|
||||
if (!key) return null
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
const selected = shortcutKey === id
|
||||
const flexBasis = `${100 / group.columns}%` as const
|
||||
return (
|
||||
|
||||
@@ -79,11 +79,21 @@ function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number
|
||||
}
|
||||
|
||||
export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
|
||||
if (agentId === 'claude') return <ClaudeIcon size={size} />
|
||||
if (agentId === 'codex') return <OpenAIIcon size={size} />
|
||||
if (agentId === 'pi') return <PiIcon size={size} />
|
||||
if (agentId === 'omp') return <OmpIcon size={size} />
|
||||
if (agentId === 'aider') return <AiderIcon size={size} />
|
||||
if (agentId === 'claude') {
|
||||
return <ClaudeIcon size={size} />
|
||||
}
|
||||
if (agentId === 'codex') {
|
||||
return <OpenAIIcon size={size} />
|
||||
}
|
||||
if (agentId === 'pi') {
|
||||
return <PiIcon size={size} />
|
||||
}
|
||||
if (agentId === 'omp') {
|
||||
return <OmpIcon size={size} />
|
||||
}
|
||||
if (agentId === 'aider') {
|
||||
return <AiderIcon size={size} />
|
||||
}
|
||||
if (agentId === '__blank__' || agentId === 'blank') {
|
||||
return <Terminal size={size} color={colors.textMuted} />
|
||||
}
|
||||
|
||||
@@ -29,13 +29,21 @@ const EDITOR_DOCUMENT_URL = `${EDITOR_DOCUMENT_ORIGIN}/rich-markdown-editor`
|
||||
|
||||
function normalizeExternalEditorUrl(value: string): string | null {
|
||||
const url = value.trim()
|
||||
if (!url) return null
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
for (let index = 0; index < url.length; index += 1) {
|
||||
const code = url.charCodeAt(index)
|
||||
if (code <= 32 || code === 127) return null
|
||||
if (code <= 32 || code === 127) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (/^mailto:/i.test(url)) {
|
||||
return url
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return null
|
||||
}
|
||||
if (/^mailto:/i.test(url)) return url
|
||||
if (!/^https?:\/\//i.test(url)) return null
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null
|
||||
@@ -128,7 +136,9 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyRef.current) return
|
||||
if (!readyRef.current) {
|
||||
return
|
||||
}
|
||||
if (currentWebViewContentRef.current !== content) {
|
||||
applyContent(content)
|
||||
}
|
||||
@@ -148,7 +158,9 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
const editorMessage = message as Partial<EditorWebViewMessage>
|
||||
if ('type' in message && message.type === 'ready') {
|
||||
readyRef.current = true
|
||||
|
||||
@@ -79,7 +79,9 @@ type SetupTrustPrompt = {
|
||||
function repoColor(name: string): string {
|
||||
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
for (let i = 0; i < name.length; i += 1) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0
|
||||
}
|
||||
return palette[Math.abs(hash) % palette.length]!
|
||||
}
|
||||
|
||||
@@ -230,7 +232,9 @@ export function NewWorktreeModal({
|
||||
setShowAgentPicker(false)
|
||||
return
|
||||
}
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
setName('')
|
||||
setNote('')
|
||||
@@ -262,7 +266,9 @@ export function NewWorktreeModal({
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('ui.get')
|
||||
])
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (settingsResponse.ok) {
|
||||
const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings }
|
||||
setRuntimeSettings(result.settings)
|
||||
@@ -283,9 +289,13 @@ export function NewWorktreeModal({
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!stale) setRepos([])
|
||||
if (!stale) {
|
||||
setRepos([])
|
||||
}
|
||||
} finally {
|
||||
if (!stale) setLoading(false)
|
||||
if (!stale) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -303,7 +313,9 @@ export function NewWorktreeModal({
|
||||
void client
|
||||
.sendRequest('ssh.getState', { targetId: selectedRepoConnectionId })
|
||||
.then((response) => {
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
@@ -333,7 +345,9 @@ export function NewWorktreeModal({
|
||||
}, [client, selectedRepoConnectionId, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !client) return
|
||||
if (!visible || !client) {
|
||||
return
|
||||
}
|
||||
if (selectedRepoConnectionId && sshGate.status !== 'connected') {
|
||||
setDetectedAgentIds(null)
|
||||
return
|
||||
@@ -347,12 +361,16 @@ export function NewWorktreeModal({
|
||||
connectionId: selectedRepoConnectionId
|
||||
})
|
||||
: await client.sendRequest('preflight.detectAgents')
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setDetectedAgentIds(
|
||||
response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
|
||||
)
|
||||
} catch {
|
||||
if (!stale) setDetectedAgentIds(new Set())
|
||||
if (!stale) {
|
||||
setDetectedAgentIds(new Set())
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -373,7 +391,9 @@ export function NewWorktreeModal({
|
||||
const response = await client.sendRequest('repo.hooks', {
|
||||
repo: `id:${selectedRepo.id}`
|
||||
})
|
||||
if (stale) return
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as RepoHooksResponse
|
||||
const cmd = result.hooks?.scripts?.setup?.trim() || null
|
||||
@@ -404,7 +424,9 @@ export function NewWorktreeModal({
|
||||
}, [client, selectedRepo])
|
||||
|
||||
async function connectSelectedSshRepo(): Promise<void> {
|
||||
if (!client || !selectedRepoConnectionId) return
|
||||
if (!client || !selectedRepoConnectionId) {
|
||||
return
|
||||
}
|
||||
setSshConnecting(true)
|
||||
setSshState({
|
||||
targetId: selectedRepoConnectionId,
|
||||
@@ -447,7 +469,9 @@ export function NewWorktreeModal({
|
||||
contentHash: string,
|
||||
alwaysTrust: boolean
|
||||
): Promise<void> {
|
||||
if (!client) return
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const next = trustedOrcaHooksWithSetupApproval({
|
||||
trust: trustedOrcaHooks,
|
||||
repoId,
|
||||
@@ -462,7 +486,9 @@ export function NewWorktreeModal({
|
||||
}
|
||||
|
||||
async function handleCreate(options: CreateOptions = {}) {
|
||||
if (!client || !selectedRepo) return
|
||||
if (!client || !selectedRepo) {
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setError('')
|
||||
|
||||
@@ -566,8 +592,12 @@ export function NewWorktreeModal({
|
||||
setupDecision,
|
||||
name: candidateName
|
||||
}
|
||||
if (selectedAgent.id !== '__blank__') params.createdWithAgent = selectedAgent.id
|
||||
if (note.trim()) params.comment = note.trim()
|
||||
if (selectedAgent.id !== '__blank__') {
|
||||
params.createdWithAgent = selectedAgent.id
|
||||
}
|
||||
if (note.trim()) {
|
||||
params.comment = note.trim()
|
||||
}
|
||||
|
||||
const response = await client.sendRequest('worktree.create', params, {
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
@@ -715,7 +745,9 @@ export function NewWorktreeModal({
|
||||
autoFocus={repos.length <= 1}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canCreate) void handleCreate()
|
||||
if (canCreate) {
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -79,14 +79,18 @@ function PickerModalContent<T extends string = string>({
|
||||
opt.disabled && styles.rowDisabled
|
||||
]}
|
||||
onPress={() => {
|
||||
if (opt.disabled) return
|
||||
if (opt.disabled) {
|
||||
return
|
||||
}
|
||||
onSelect(opt.value)
|
||||
onClose()
|
||||
}}
|
||||
onLongPress={
|
||||
onLongSelect
|
||||
? () => {
|
||||
if (opt.disabled) return
|
||||
if (opt.disabled) {
|
||||
return
|
||||
}
|
||||
onLongSelect(opt.value)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
|
||||
code.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.length) index += 1
|
||||
if (index < lines.length) {
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@ function extractFunctionSource(script: string, name: string): string {
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < script.length; index += 1) {
|
||||
const char = script[index]
|
||||
if (char === '{') depth += 1
|
||||
if (char === '{') {
|
||||
depth += 1
|
||||
}
|
||||
if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) return script.slice(start, index + 1)
|
||||
if (depth === 0) {
|
||||
return script.slice(start, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not extract ${name}`)
|
||||
|
||||
@@ -39,13 +39,17 @@ export function getSuggestedCreatureName(
|
||||
}
|
||||
// Lowercased to match branch-name convention (fix/seahorse, not fix/Seahorse).
|
||||
const available = MARINE_CREATURES.map(normalize).filter((name) => !used.has(name))
|
||||
if (available.length > 0) return pickRandom(available, random)
|
||||
if (available.length > 0) {
|
||||
return pickRandom(available, random)
|
||||
}
|
||||
let suffix = 2
|
||||
while (true) {
|
||||
const numbered = MARINE_CREATURES.map((name) => `${normalize(name)}-${suffix}`).filter(
|
||||
(name) => !used.has(name)
|
||||
)
|
||||
if (numbered.length > 0) return pickRandom(numbered, random)
|
||||
if (numbered.length > 0) {
|
||||
return pickRandom(numbered, random)
|
||||
}
|
||||
suffix += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export function startDiagnosticFetchTimeout(timeoutMs: number): DiagnosticFetchT
|
||||
}, timeoutMs)
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
|
||||
@@ -58,10 +58,14 @@ function configureNotificationChannel(): void {
|
||||
|
||||
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) return
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) return
|
||||
if (!granted) {
|
||||
return
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
@@ -99,10 +103,14 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
return
|
||||
}
|
||||
if (event.type === 'end') {
|
||||
if (disposed) unsubscribeStream()
|
||||
if (disposed) {
|
||||
unsubscribeStream()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
|
||||
@@ -43,15 +43,21 @@ export function formatDiffComments(comments: readonly DiffComment[]): string {
|
||||
}
|
||||
|
||||
export function normalizeMobileDiffComments(value: unknown, worktreeId: string): DiffComment[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.flatMap((candidate): DiffComment[] => {
|
||||
if (!isRecord(candidate)) return []
|
||||
if (!isRecord(candidate)) {
|
||||
return []
|
||||
}
|
||||
const id = typeof candidate.id === 'string' ? candidate.id : ''
|
||||
const filePath = typeof candidate.filePath === 'string' ? candidate.filePath : ''
|
||||
const lineNumber = typeof candidate.lineNumber === 'number' ? candidate.lineNumber : NaN
|
||||
const body = typeof candidate.body === 'string' ? candidate.body.trim() : ''
|
||||
const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now()
|
||||
if (!id || !filePath || !Number.isFinite(lineNumber) || !body) return []
|
||||
if (!id || !filePath || !Number.isFinite(lineNumber) || !body) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
id,
|
||||
@@ -103,7 +109,9 @@ export function removeMobileDiffComments(
|
||||
comments: readonly DiffComment[],
|
||||
ids: ReadonlySet<string>
|
||||
): DiffComment[] {
|
||||
if (ids.size === 0) return [...comments]
|
||||
if (ids.size === 0) {
|
||||
return [...comments]
|
||||
}
|
||||
return comments.filter((comment) => !ids.has(comment.id))
|
||||
}
|
||||
|
||||
@@ -123,7 +131,9 @@ export function removeDeliveredMobileDiffComments(
|
||||
comments: readonly DiffComment[],
|
||||
delivered: readonly DiffComment[]
|
||||
): DiffComment[] {
|
||||
if (delivered.length === 0) return [...comments]
|
||||
if (delivered.length === 0) {
|
||||
return [...comments]
|
||||
}
|
||||
const deliveredById = new Map(delivered.map((comment) => [comment.id, comment]))
|
||||
return comments.filter((comment) => {
|
||||
const snapshot = deliveredById.get(comment.id)
|
||||
|
||||
@@ -172,14 +172,30 @@ function tokenKindForClasses(className: unknown): MobileSyntaxTokenKind | null {
|
||||
: []
|
||||
|
||||
const tokens = new Set(classes.map((value) => value.replace(/^hljs-/, '')))
|
||||
if (hasAny(tokens, ['comment', 'quote'])) return 'comment'
|
||||
if (hasAny(tokens, ['keyword', 'selector-tag', 'tag', 'name'])) return 'keyword'
|
||||
if (hasAny(tokens, ['string', 'regexp', 'symbol', 'bullet'])) return 'string'
|
||||
if (hasAny(tokens, ['number', 'literal'])) return 'number'
|
||||
if (hasAny(tokens, ['type', 'built_in', 'class', 'title.class'])) return 'type'
|
||||
if (hasAny(tokens, ['title.function', 'function', 'title'])) return 'function'
|
||||
if (hasAny(tokens, ['attr', 'attribute', 'property', 'variable', 'params'])) return 'variable'
|
||||
if (hasAny(tokens, ['meta', 'doctag', 'subst', 'section'])) return 'meta'
|
||||
if (hasAny(tokens, ['comment', 'quote'])) {
|
||||
return 'comment'
|
||||
}
|
||||
if (hasAny(tokens, ['keyword', 'selector-tag', 'tag', 'name'])) {
|
||||
return 'keyword'
|
||||
}
|
||||
if (hasAny(tokens, ['string', 'regexp', 'symbol', 'bullet'])) {
|
||||
return 'string'
|
||||
}
|
||||
if (hasAny(tokens, ['number', 'literal'])) {
|
||||
return 'number'
|
||||
}
|
||||
if (hasAny(tokens, ['type', 'built_in', 'class', 'title.class'])) {
|
||||
return 'type'
|
||||
}
|
||||
if (hasAny(tokens, ['title.function', 'function', 'title'])) {
|
||||
return 'function'
|
||||
}
|
||||
if (hasAny(tokens, ['attr', 'attribute', 'property', 'variable', 'params'])) {
|
||||
return 'variable'
|
||||
}
|
||||
if (hasAny(tokens, ['meta', 'doctag', 'subst', 'section'])) {
|
||||
return 'meta'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,12 @@ function compareGitStatusEntries(a: MobileGitStatusEntry, b: MobileGitStatusEntr
|
||||
}
|
||||
|
||||
function getConflictSortRank(entry: MobileGitStatusEntry): number {
|
||||
if (entry.conflictStatus === 'unresolved') return 0
|
||||
if (entry.conflictStatus === 'resolved_locally') return 1
|
||||
if (entry.conflictStatus === 'unresolved') {
|
||||
return 0
|
||||
}
|
||||
if (entry.conflictStatus === 'resolved_locally') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ const NOTIF_KEY = 'orca:pushNotificationsEnabled'
|
||||
export async function loadPushNotificationsEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(NOTIF_KEY)
|
||||
if (raw === null) return false
|
||||
if (raw === null) {
|
||||
return false
|
||||
}
|
||||
return raw === 'true'
|
||||
} catch {
|
||||
return false
|
||||
@@ -55,7 +57,9 @@ function allowedString(value: unknown, allowed: Set<string>, fallback: string):
|
||||
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
|
||||
if (!raw) return new Set()
|
||||
if (!raw) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(stringArray(JSON.parse(raw)))
|
||||
} catch {
|
||||
return new Set()
|
||||
@@ -69,7 +73,9 @@ export async function savePinnedIds(hostId: string, ids: Set<string>): Promise<v
|
||||
export async function loadPreferences(hostId: string): Promise<HostPreferences> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId)
|
||||
if (!raw) return DEFAULT_PREFS
|
||||
if (!raw) {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<HostPreferences>
|
||||
return {
|
||||
sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode),
|
||||
|
||||
@@ -16,9 +16,13 @@ type CachedSlugState =
|
||||
|
||||
export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return null
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const [owner, repo, extra] = trimmed.split('/')
|
||||
if (!owner || !repo || extra) return null
|
||||
if (!owner || !repo || extra) {
|
||||
return null
|
||||
}
|
||||
return `${owner}/${repo}`.toLowerCase()
|
||||
}
|
||||
|
||||
@@ -27,8 +31,12 @@ function cachedSlugStateForRepo(
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined>
|
||||
): CachedSlugState {
|
||||
const cached = slugsByRepoId[repo.id]
|
||||
if (!cached) return { status: 'missing' }
|
||||
if (cached.path !== repo.path) return { status: 'stale' }
|
||||
if (!cached) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
if (cached.path !== repo.path) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) }
|
||||
}
|
||||
|
||||
@@ -38,7 +46,9 @@ export function findRepoForGitHubProjectRepository(
|
||||
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
|
||||
): GitHubProjectRepoMatch | null {
|
||||
const slug = normalizeGitHubRepositorySlug(repository)
|
||||
if (!slug) return null
|
||||
if (!slug) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slugStates = new Map(
|
||||
repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)])
|
||||
@@ -47,8 +57,12 @@ export function findRepoForGitHubProjectRepository(
|
||||
const state = slugStates.get(repo.id)
|
||||
return state?.status === 'resolved' && state.slug === slug
|
||||
})
|
||||
if (slugMatches.length === 1) return slugMatches[0]!
|
||||
if (slugMatches.length > 1) return null
|
||||
if (slugMatches.length === 1) {
|
||||
return slugMatches[0]!
|
||||
}
|
||||
if (slugMatches.length > 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
repos.find((repo) => {
|
||||
|
||||
@@ -12,13 +12,27 @@ function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): bo
|
||||
}
|
||||
|
||||
export function workspaceSshStatusLabel(status: SshConnectionStatus | null): string {
|
||||
if (status === 'connected') return 'Connected'
|
||||
if (status === 'connecting') return 'Connecting'
|
||||
if (status === 'deploying-relay') return 'Deploying relay'
|
||||
if (status === 'reconnecting') return 'Reconnecting'
|
||||
if (status === 'auth-failed') return 'Authentication failed'
|
||||
if (status === 'reconnection-failed') return 'Reconnect failed'
|
||||
if (status === 'error') return 'Connection failed'
|
||||
if (status === 'connected') {
|
||||
return 'Connected'
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
return 'Connecting'
|
||||
}
|
||||
if (status === 'deploying-relay') {
|
||||
return 'Deploying relay'
|
||||
}
|
||||
if (status === 'reconnecting') {
|
||||
return 'Reconnecting'
|
||||
}
|
||||
if (status === 'auth-failed') {
|
||||
return 'Authentication failed'
|
||||
}
|
||||
if (status === 'reconnection-failed') {
|
||||
return 'Reconnect failed'
|
||||
}
|
||||
if (status === 'error') {
|
||||
return 'Connection failed'
|
||||
}
|
||||
return 'Disconnected'
|
||||
}
|
||||
|
||||
|
||||
@@ -2059,7 +2059,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
})
|
||||
} else if (msg.type === 'terminal-input') {
|
||||
const bytes = typeof msg.bytes === 'string' ? msg.bytes : ''
|
||||
if (bytes.length > 0) onTerminalInput?.(bytes)
|
||||
if (bytes.length > 0) {
|
||||
onTerminalInput?.(bytes)
|
||||
}
|
||||
} else if (msg.type === 'terminal-tap') {
|
||||
onTerminalTap?.()
|
||||
} else if (msg.type === 'keyboard-avoidance-metrics') {
|
||||
@@ -2144,7 +2146,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
measureFitDimensions(
|
||||
containerHeight?: number
|
||||
): Promise<{ cols: number; rows: number } | null> {
|
||||
if (!isWebReadyRef.current) return Promise.resolve(null)
|
||||
if (!isWebReadyRef.current) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
measureResolveRef.current?.(null)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -2184,7 +2188,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
// immediately if no init is pending. Capped at 3s so a stuck
|
||||
// WebView doesn't hang the caller.
|
||||
const p = readyPromiseRef.current
|
||||
if (!p) return
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -23,7 +23,9 @@ function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreferenc
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
if (!Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value.every((item): item is string => typeof item === 'string') ? value : null
|
||||
}
|
||||
|
||||
@@ -31,7 +33,9 @@ function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const id of ids) {
|
||||
if (!builtInSet.has(id) || seen.has(id)) continue
|
||||
if (!builtInSet.has(id) || seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
@@ -52,7 +56,9 @@ export function normalizeTerminalAccessoryLayoutPreference(
|
||||
currentBuiltInIds = builtInIds()
|
||||
): TerminalAccessoryLayoutPreference {
|
||||
const fallback = defaultPreference(currentBuiltInIds)
|
||||
if (!value || typeof value !== 'object') return fallback
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const candidate = value as {
|
||||
version?: unknown
|
||||
@@ -61,7 +67,9 @@ export function normalizeTerminalAccessoryLayoutPreference(
|
||||
}
|
||||
const visibleInput = stringArray(candidate.visibleBuiltInIds)
|
||||
const knownInput = stringArray(candidate.knownBuiltInIds)
|
||||
if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback
|
||||
if (candidate.version !== 1 || !visibleInput || !knownInput) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const builtInSet = new Set(currentBuiltInIds)
|
||||
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
|
||||
@@ -129,7 +137,9 @@ export function getVisibleTerminalAccessoryKeys(
|
||||
export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLayoutPreference> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY)
|
||||
if (!raw) return defaultPreference()
|
||||
if (!raw) {
|
||||
return defaultPreference()
|
||||
}
|
||||
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
|
||||
} catch {
|
||||
return defaultPreference()
|
||||
|
||||
@@ -37,7 +37,9 @@ function isSgrMouseGestureSequence(bytes: string, offset: number): number | null
|
||||
}
|
||||
const sequence = bytes.slice(offset, end + 1)
|
||||
const match = SGR_MOUSE_GESTURE_SEQUENCE_RE.exec(sequence)
|
||||
if (!match) return null
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const button = match[1]
|
||||
const col = Number(match[2])
|
||||
const row = Number(match[3])
|
||||
|
||||
@@ -40,8 +40,12 @@ export type BrowserScreencastFrame = {
|
||||
}
|
||||
|
||||
function byteToFormat(value: number): BrowserScreencastFormat | null {
|
||||
if (value === 1) return 'jpeg'
|
||||
if (value === 2) return 'png'
|
||||
if (value === 1) {
|
||||
return 'jpeg'
|
||||
}
|
||||
if (value === 2) {
|
||||
return 'png'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -74,17 +74,25 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
function notifyHostState(hostId: string, state: ConnectionState) {
|
||||
const set = stateListenersRef.current.get(hostId)
|
||||
if (!set) return
|
||||
for (const listener of set) listener(state)
|
||||
if (!set) {
|
||||
return
|
||||
}
|
||||
for (const listener of set) {
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAllHosts() {
|
||||
for (const listener of allHostsListenersRef.current) listener()
|
||||
for (const listener of allHostsListenersRef.current) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
const closeEntry = useCallback((hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (!entry) return
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.unsubState()
|
||||
entry.client.close()
|
||||
storeRef.current.delete(hostId)
|
||||
@@ -123,12 +131,16 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
notifyAllHosts()
|
||||
return null
|
||||
}
|
||||
if (!host) return null
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after any await — another acquire() may have completed.
|
||||
const after = storeRef.current.get(hostId)
|
||||
if (after) return after
|
||||
if (after) {
|
||||
return after
|
||||
}
|
||||
|
||||
let client: RpcClient
|
||||
try {
|
||||
@@ -143,7 +155,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
const unsubState = client.onStateChange((state) => {
|
||||
const cur = storeRef.current.get(hostId)
|
||||
if (!cur) return
|
||||
if (!cur) {
|
||||
return
|
||||
}
|
||||
cur.state = state
|
||||
notifyHostState(hostId, state)
|
||||
})
|
||||
@@ -171,7 +185,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
// pass inside openEntry.
|
||||
const acquire = useCallback(
|
||||
(hostId: string, host?: HostProfile): RpcClient | null => {
|
||||
if (host) primedHostsRef.current.set(hostId, host)
|
||||
if (host) {
|
||||
primedHostsRef.current.set(hostId, host)
|
||||
}
|
||||
const existing = storeRef.current.get(hostId)
|
||||
if (existing) {
|
||||
existing.refCount += 1
|
||||
@@ -181,7 +197,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
// try again once the state listener fires; consumers are expected to
|
||||
// call acquire() inside an effect that re-runs on state changes.
|
||||
void openEntry(hostId).then((entry) => {
|
||||
if (!entry) return
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.refCount += 1
|
||||
})
|
||||
return null
|
||||
@@ -190,7 +208,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const primeHosts = useCallback((hosts: HostProfile[]) => {
|
||||
for (const host of hosts) primedHostsRef.current.set(host.id, host)
|
||||
for (const host of hosts) {
|
||||
primedHostsRef.current.set(host.id, host)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: refcount dropping to 0 no longer triggers an idle-close. The
|
||||
@@ -203,7 +223,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
// unmount (app shutdown).
|
||||
const release = useCallback((hostId: string) => {
|
||||
const entry = storeRef.current.get(hostId)
|
||||
if (!entry) return
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.refCount = Math.max(0, entry.refCount - 1)
|
||||
}, [])
|
||||
|
||||
@@ -222,7 +244,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
storeRef.current.delete(hostId)
|
||||
}
|
||||
const fresh = await openEntry(hostId)
|
||||
if (fresh) fresh.refCount = savedRefCount
|
||||
if (fresh) {
|
||||
fresh.refCount = savedRefCount
|
||||
}
|
||||
},
|
||||
[openEntry]
|
||||
)
|
||||
@@ -249,9 +273,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
set.add(listener)
|
||||
return () => {
|
||||
const s = stateListenersRef.current.get(hostId)
|
||||
if (!s) return
|
||||
if (!s) {
|
||||
return
|
||||
}
|
||||
s.delete(listener)
|
||||
if (s.size === 0) stateListenersRef.current.delete(hostId)
|
||||
if (s.size === 0) {
|
||||
stateListenersRef.current.delete(hostId)
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
@@ -286,7 +314,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = storeRef.current
|
||||
return () => {
|
||||
for (const [hostId] of store) closeEntry(hostId)
|
||||
for (const [hostId] of store) {
|
||||
closeEntry(hostId)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -324,7 +354,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
function useCtx(): ContextValue {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useHostClient must be used inside <RpcClientProvider>')
|
||||
if (!ctx) {
|
||||
throw new Error('useHostClient must be used inside <RpcClientProvider>')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -351,7 +383,9 @@ export function useHostClient(hostId: string | undefined): {
|
||||
let cancelled = false
|
||||
// Subscribe before acquire so any state change during open is captured.
|
||||
const unsub = ctx.subscribeHostState(hostId, (next) => {
|
||||
if (cancelled) return
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setState(next)
|
||||
// Why: if the client was null at first acquire (async open), the
|
||||
// first state change ('connecting'/'handshaking'/'connected') is our
|
||||
@@ -395,16 +429,24 @@ export function useAllHostClients(hostIds: string[]): Array<{
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (hostIds.length === 0) return
|
||||
for (const id of hostIds) ctx.acquire(id)
|
||||
if (hostIds.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const id of hostIds) {
|
||||
ctx.acquire(id)
|
||||
}
|
||||
const unsubs: Array<() => void> = []
|
||||
for (const id of hostIds) {
|
||||
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
|
||||
}
|
||||
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
|
||||
return () => {
|
||||
for (const u of unsubs) u()
|
||||
for (const id of hostIds) ctx.release(id)
|
||||
for (const u of unsubs) {
|
||||
u()
|
||||
}
|
||||
for (const id of hostIds) {
|
||||
ctx.release(id)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key])
|
||||
@@ -454,7 +496,9 @@ export function useReconnectAttempt(hostId: string | undefined): number {
|
||||
const ctx = useCtx()
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
|
||||
}, [ctx, hostId])
|
||||
return hostId ? ctx.getReconnectAttempt(hostId) : 0
|
||||
@@ -469,7 +513,9 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
|
||||
const ctx = useCtx()
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
if (!hostId) {
|
||||
return
|
||||
}
|
||||
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
|
||||
}, [ctx, hostId])
|
||||
return hostId ? ctx.getLastConnectedAt(hostId) : null
|
||||
|
||||
@@ -43,7 +43,9 @@ export function classifyConnection(args: {
|
||||
}
|
||||
|
||||
// Connected / connecting / handshaking are normal.
|
||||
if (state === 'connected') return { kind: 'normal', label: 'Connected' }
|
||||
if (state === 'connected') {
|
||||
return { kind: 'normal', label: 'Connected' }
|
||||
}
|
||||
if (state === 'connecting' || state === 'handshaking') {
|
||||
return { kind: 'normal', label: 'Connecting…' }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ export function getNextHostNameFromHosts(hosts: readonly HostNameSource[]): stri
|
||||
|
||||
for (const host of hosts) {
|
||||
const match = HOST_NUMBER_PATTERN.exec(host.name)
|
||||
if (!match) continue
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const hostNumber = Number.parseInt(match[1]!, 10)
|
||||
if (hostNumber > largestHostNumber) {
|
||||
|
||||
@@ -39,7 +39,9 @@ let inflightLoad: Promise<HostProfile[]> | null = null
|
||||
export async function loadHosts(): Promise<HostProfile[]> {
|
||||
// Why: deduplicate concurrent loadHosts() calls so multiple screens
|
||||
// mounting simultaneously share one Keychain read pass.
|
||||
if (inflightLoad) return inflightLoad
|
||||
if (inflightLoad) {
|
||||
return inflightLoad
|
||||
}
|
||||
inflightLoad = doLoadHosts().finally(() => {
|
||||
inflightLoad = null
|
||||
})
|
||||
@@ -48,14 +50,18 @@ export async function loadHosts(): Promise<HostProfile[]> {
|
||||
|
||||
async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(parsed)) return []
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const out: HostProfile[] = []
|
||||
for (const item of parsed) {
|
||||
@@ -67,7 +73,9 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
continue
|
||||
}
|
||||
const stored = StoredHostProfileSchema.safeParse(item)
|
||||
if (!stored.success) continue
|
||||
if (!stored.success) {
|
||||
continue
|
||||
}
|
||||
|
||||
let token = tokenCache.get(stored.data.id)
|
||||
if (!token) {
|
||||
@@ -97,14 +105,20 @@ async function doLoadHosts(): Promise<HostProfile[]> {
|
||||
|
||||
async function loadStoredHosts(): Promise<StoredHostProfile[]> {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
return parsed.flatMap((item) => {
|
||||
// Why: same drop-old-records rule as loadHosts; keeps internal
|
||||
// mutators from re-persisting pre-v0.0.3 entries.
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) return []
|
||||
if (item && typeof item === 'object' && 'deviceToken' in item) {
|
||||
return []
|
||||
}
|
||||
const result = StoredHostProfileSchema.safeParse(item)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
|
||||
@@ -16,13 +16,17 @@ export function startPairingConnectionAttempt({
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function closeClientOnce() {
|
||||
if (clientClosed) return
|
||||
if (clientClosed) {
|
||||
return
|
||||
}
|
||||
clientClosed = true
|
||||
closeClient()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
|
||||
@@ -8,7 +8,9 @@ import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
try {
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (!code) return null
|
||||
if (!code) {
|
||||
return null
|
||||
}
|
||||
return decodePairingBase64(code)
|
||||
} catch {
|
||||
return null
|
||||
@@ -19,7 +21,9 @@ export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
// extraction here makes QR scan, paste, and external deep-link flows
|
||||
// accept the same URL shapes.
|
||||
export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) return null
|
||||
if (!url.startsWith('orca://pair')) {
|
||||
return null
|
||||
}
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const query = url.slice(queryIndex + 1).split('#')[0] ?? ''
|
||||
@@ -41,7 +45,9 @@ export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
// copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return null
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (trimmed.startsWith('orca://pair')) {
|
||||
return decodePairingUrl(trimmed)
|
||||
|
||||
@@ -34,7 +34,9 @@ class MockWebSocket {
|
||||
emitCloseOnClose = true
|
||||
sent: string[] = []
|
||||
close = vi.fn(() => {
|
||||
if (this.readyState === MockWebSocket.CLOSED) return
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
if (this.emitCloseOnClose) {
|
||||
this.onclose?.()
|
||||
|
||||
@@ -149,7 +149,9 @@ export function connect(
|
||||
const onLog = options.onLog
|
||||
let logCounter = 0
|
||||
function emitLog(level: ConnectionLogLevel, message: string, detail?: string) {
|
||||
if (!onLog) return
|
||||
if (!onLog) {
|
||||
return
|
||||
}
|
||||
onLog({
|
||||
id: `log-${++logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
@@ -216,7 +218,9 @@ export function connect(
|
||||
}
|
||||
|
||||
function setState(next: ConnectionState) {
|
||||
if (state === next) return
|
||||
if (state === next) {
|
||||
return
|
||||
}
|
||||
const prev = state
|
||||
const dwelt = Date.now() - stateEnteredAt
|
||||
state = next
|
||||
@@ -258,8 +262,12 @@ export function connect(
|
||||
}
|
||||
|
||||
function waitForConnected(timeoutMs?: number): Promise<void> {
|
||||
if (state === 'connected') return Promise.resolve()
|
||||
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
|
||||
if (state === 'connected') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
return Promise.reject(new Error('Client closed'))
|
||||
}
|
||||
if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) {
|
||||
// Why: after the retry cap there is no future state transition to
|
||||
// release callers waiting before their per-request timeout starts.
|
||||
@@ -290,7 +298,9 @@ export function connect(
|
||||
}
|
||||
|
||||
function openConnection() {
|
||||
if (intentionallyClosed) return
|
||||
if (intentionallyClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
wsConstructionCounter++
|
||||
@@ -644,10 +654,14 @@ export function connect(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
if (seen.has(v as object)) {
|
||||
return '[circular]'
|
||||
}
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
if (typeof v === 'function') {
|
||||
return '[fn]'
|
||||
}
|
||||
return v
|
||||
},
|
||||
0
|
||||
@@ -696,10 +710,14 @@ export function connect(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
if (seen.has(v as object)) {
|
||||
return '[circular]'
|
||||
}
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
if (typeof v === 'function') {
|
||||
return '[fn]'
|
||||
}
|
||||
return v
|
||||
},
|
||||
0
|
||||
@@ -798,7 +816,9 @@ export function connect(
|
||||
// Why: only probe while the channel is actually in 'connected'. The
|
||||
// sendRequest path itself waits for connected, but a probe scheduled
|
||||
// during a reconnect would just stack up timeouts and confuse logs.
|
||||
if (state !== 'connected' || !ws) return
|
||||
if (state !== 'connected' || !ws) {
|
||||
return
|
||||
}
|
||||
const probeWs = ws
|
||||
// Why: short timeout (8s) — server's heartbeat is 15s, so if we
|
||||
// don't see *anything* back within 8s the link is almost certainly
|
||||
@@ -823,11 +843,15 @@ export function connect(
|
||||
}, 8_000)
|
||||
pending.set(id, {
|
||||
resolve: () => {
|
||||
if (timedOut) return
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
},
|
||||
reject: () => {
|
||||
if (timedOut) return
|
||||
if (timedOut) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user