soja-cli 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { slugify } from '../../domain/naming.js';
|
|
5
|
+
import { toDisplayError } from '../../utils/errors.js';
|
|
6
|
+
import { Splash } from '../branding/Splash.js';
|
|
7
|
+
import { TextField } from '../components/TextField.js';
|
|
8
|
+
import { useTerminalSize } from '../hooks/use-terminal-size.js';
|
|
9
|
+
import { Button } from '../kit/Button.js';
|
|
10
|
+
import { Gauge } from '../kit/Gauge.js';
|
|
11
|
+
import { Panel } from '../kit/Panel.js';
|
|
12
|
+
import { Spinner } from '../kit/Spinner.js';
|
|
13
|
+
import { Layer } from '../input/dispatcher.js';
|
|
14
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
15
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
16
|
+
const STEPS = ['displayName', 'username', 'workspaceName'];
|
|
17
|
+
const QUESTIONS = {
|
|
18
|
+
displayName: "What's your name?",
|
|
19
|
+
username: 'Username',
|
|
20
|
+
workspaceName: 'Workspace name',
|
|
21
|
+
};
|
|
22
|
+
const PLACEHOLDERS = {
|
|
23
|
+
displayName: 'Michael',
|
|
24
|
+
username: 'michael',
|
|
25
|
+
workspaceName: 'Bravos Development',
|
|
26
|
+
};
|
|
27
|
+
/** First run: three questions, then straight into SOJA. */
|
|
28
|
+
export function SetupScreen({ services, onDone }) {
|
|
29
|
+
const [step, setStep] = useState(0);
|
|
30
|
+
const [values, setValues] = useState({ displayName: '', username: '', workspaceName: '' });
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const current = STEPS[step] ?? 'displayName';
|
|
34
|
+
const advance = async () => {
|
|
35
|
+
if (busy)
|
|
36
|
+
return;
|
|
37
|
+
if (!values[current].trim()) {
|
|
38
|
+
setError('This one is required.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
setError(null);
|
|
42
|
+
if (current === 'displayName' && !values.username) {
|
|
43
|
+
// Suggest a username from the name; it stays editable.
|
|
44
|
+
setValues((previous) => ({ ...previous, username: slugify(previous.displayName).replace(/-/g, '') }));
|
|
45
|
+
}
|
|
46
|
+
if (step < STEPS.length - 1) {
|
|
47
|
+
setStep(step + 1);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setBusy(true);
|
|
51
|
+
try {
|
|
52
|
+
onDone(await services.session.setup(values));
|
|
53
|
+
}
|
|
54
|
+
catch (failure) {
|
|
55
|
+
setError(toDisplayError(failure).message);
|
|
56
|
+
setBusy(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
useKeys(Layer.screen, (_input, key) => {
|
|
60
|
+
if (key.return)
|
|
61
|
+
void advance();
|
|
62
|
+
else if (key.escape && step > 0) {
|
|
63
|
+
setError(null);
|
|
64
|
+
setStep(step - 1);
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
const { columns, rows } = useTerminalSize();
|
|
71
|
+
const width = Math.min(76, columns - 4);
|
|
72
|
+
const stepTitles = { displayName: 'Your name', username: 'Username', workspaceName: 'Workspace' };
|
|
73
|
+
const helpers = {
|
|
74
|
+
displayName: 'How your teammates see you in SOJA.',
|
|
75
|
+
username: 'Used in @mentions and assignments. Letters, numbers and dashes.',
|
|
76
|
+
workspaceName: 'Your team or company. You can create more later.',
|
|
77
|
+
};
|
|
78
|
+
return (_jsx(Box, { width: columns, height: rows, alignItems: "center", justifyContent: "center", children: _jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Splash, { compact: true }) }), _jsxs(Panel, { title: "First setup", aside: `step ${step + 1} of ${STEPS.length}`, focused: true, width: width, children: [_jsx(Box, { gap: 1, marginBottom: 1, children: STEPS.map((name, index) => (_jsxs(Box, { gap: 1, children: [index > 0 ? _jsx(Text, { color: index <= step ? palette.accent : palette.border, children: '──' }) : null, _jsx(Text, { ...(index === step ? { backgroundColor: palette.accent, color: palette.onAccent, bold: true } : { color: index < step ? palette.success : palette.faint }), children: ` ${index < step ? symbols.check : String(index + 1)} ${stepTitles[name]} ` })] }, name))) }), _jsx(Text, { color: palette.text, bold: true, children: QUESTIONS[current] }), _jsx(Text, { color: palette.muted, children: helpers[current] }), _jsx(Box, { marginTop: 1, children: _jsx(TextField, { value: values[current], onChange: (value) => setValues((previous) => ({ ...previous, [current]: value })), placeholder: PLACEHOLDERS[current], width: width - 6 }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: STEPS.slice(0, step).map((name) => (_jsx(Text, { color: palette.muted, children: `${symbols.check} ${stepTitles[name]}: ${values[name]}` }, name))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [error ? (_jsx(Text, { color: palette.danger, children: `${symbols.cross} ${error}` })) : busy ? (_jsx(Spinner, { label: "Setting things up\u2026" })) : (_jsx(Gauge, { value: step, total: STEPS.length, width: 16 })), _jsxs(Box, { gap: 2, children: [step > 0 ? _jsx(Button, { label: "Back", layer: Layer.screen, onPress: () => { setError(null); setStep(step - 1); } }) : null, _jsx(Button, { label: step === STEPS.length - 1 ? 'Finish' : `Next ${symbols.arrow}`, variant: "primary", layer: Layer.screen, onPress: () => void advance() })] })] })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.faint, children: "Everything stays on this machine \u00B7 enter next \u00B7 esc back \u00B7 ctrl+c quit" }) })] }) }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { FILTER_LABELS, TASK_FILTERS } from '../../application/filters.js';
|
|
5
|
+
import { activeCount, countStatuses, TYPE_LABELS } from '../../domain/task.js';
|
|
6
|
+
import { formatRelative } from '../../utils/time.js';
|
|
7
|
+
import { useAppState } from '../app-state.js';
|
|
8
|
+
import { EmptyState } from '../components/EmptyState.js';
|
|
9
|
+
import { PriorityMeter, StatusBadge } from '../components/Labels.js';
|
|
10
|
+
import { PullRequestSummary } from '../components/PullRequestLabel.js';
|
|
11
|
+
import { TaskTable, tableRows } from '../components/TaskTable.js';
|
|
12
|
+
import { EMPTY_TASKS, summaryRemark } from '../copy.js';
|
|
13
|
+
import { useLayout } from '../hooks/use-layout.js';
|
|
14
|
+
import { useList } from '../hooks/use-list.js';
|
|
15
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
16
|
+
import { useTaskActions } from '../hooks/use-task-actions.js';
|
|
17
|
+
import { Layer } from '../input/dispatcher.js';
|
|
18
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
19
|
+
import { Panel } from '../kit/Panel.js';
|
|
20
|
+
import { Tabs } from '../kit/Tabs.js';
|
|
21
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
22
|
+
import { Mascot } from '../mascot/Mascot.js';
|
|
23
|
+
import { sceneForTaskList } from '../mascot/selection.js';
|
|
24
|
+
import { clampLines, wrapText } from '../../utils/text.js';
|
|
25
|
+
import { ScreenFrame } from './ScreenFrame.js';
|
|
26
|
+
const HEADING = { ...FILTER_LABELS, mine: 'My work' };
|
|
27
|
+
const TAB_LABELS = { ...FILTER_LABELS, mine: 'Mine', all: 'All' };
|
|
28
|
+
const NO_TASKS = [];
|
|
29
|
+
/** Rows of the preview panel, including its borders. */
|
|
30
|
+
const PREVIEW_ROWS = 8;
|
|
31
|
+
/** "What do I have to do now?" Home is this screen with no project. */
|
|
32
|
+
export function TaskListScreen({ active, projectId, initialFilter }) {
|
|
33
|
+
const { services, session, go, cwd, homeFilter, setHomeFilter, syncStatus } = useAppState();
|
|
34
|
+
const actions = useTaskActions();
|
|
35
|
+
const { width, height } = useLayout();
|
|
36
|
+
// Home shares its filter with the sidebar; a project list keeps its own.
|
|
37
|
+
const [projectFilter, setProjectFilter] = useState(initialFilter ?? 'all');
|
|
38
|
+
const filter = projectId ? projectFilter : homeFilter;
|
|
39
|
+
const setFilter = projectId ? setProjectFilter : setHomeFilter;
|
|
40
|
+
const scope = projectId ? { projectId } : {};
|
|
41
|
+
const tasks = useQuery(() => services.tasks.list(session, filter, scope), `tasks:${session.workspace.id}:${filter}:${projectId ?? ''}`, [`tasks:${session.workspace.id}`]);
|
|
42
|
+
// GitHub pull requests of the listed tasks (one cached `gh` call per repository).
|
|
43
|
+
const withBranch = (tasks.data ?? []).filter((task) => task.branch);
|
|
44
|
+
const pullRequests = useQuery(() => services.git.pullRequestIndex(session, cwd, withBranch), `prs:${withBranch.map((task) => `${task.id}:${task.branch ?? ''}`).join(',')}`);
|
|
45
|
+
const project = useQuery(async () => (projectId ? ((await services.projects.list(session)).find((p) => p.id === projectId) ?? null) : null), `project:${projectId ?? ''}`, [`projects:${session.workspace.id}`, `tasks:${session.workspace.id}`]);
|
|
46
|
+
const list = tasks.data ?? NO_TASKS;
|
|
47
|
+
// Tabs and a gap; the project line; the preview panel when there is room for it.
|
|
48
|
+
const withPreview = height >= 22 && list.length > 0;
|
|
49
|
+
const showMascot = width >= 80 && height >= 34;
|
|
50
|
+
const mascotRows = showMascot ? 15 : 0;
|
|
51
|
+
const chromeRows = 2 + (projectId ? 2 : 0) + (withPreview ? PREVIEW_ROWS : 0);
|
|
52
|
+
const tableHeight = Math.max(3, height - chromeRows - mascotRows);
|
|
53
|
+
const selectedId = useRef(null);
|
|
54
|
+
const nav = useList(list.length, tableRows(tableHeight), true, 0, (index) => {
|
|
55
|
+
selectedId.current = list[index]?.id ?? null;
|
|
56
|
+
});
|
|
57
|
+
const selected = list[nav.index];
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (tasks.loading)
|
|
60
|
+
return;
|
|
61
|
+
const remembered = selectedId.current;
|
|
62
|
+
if (remembered) {
|
|
63
|
+
const nextIndex = list.findIndex((task) => task.id === remembered);
|
|
64
|
+
if (nextIndex >= 0) {
|
|
65
|
+
if (nextIndex !== nav.index)
|
|
66
|
+
nav.select(nextIndex);
|
|
67
|
+
selectedId.current = list[nextIndex]?.id ?? null;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
selectedId.current = list[nav.index]?.id ?? null;
|
|
72
|
+
}, [tasks.loading, tasks.data, nav.index, list, nav]);
|
|
73
|
+
const pick = (next) => {
|
|
74
|
+
selectedId.current = null;
|
|
75
|
+
setFilter(next);
|
|
76
|
+
nav.select(0);
|
|
77
|
+
// Filter changes intentionally reset to the first result, regardless of
|
|
78
|
+
// which task occupied index zero in the previous result set.
|
|
79
|
+
selectedId.current = null;
|
|
80
|
+
};
|
|
81
|
+
const setFilterAt = (index) => {
|
|
82
|
+
const next = TASK_FILTERS[(index + TASK_FILTERS.length) % TASK_FILTERS.length];
|
|
83
|
+
if (next)
|
|
84
|
+
pick(next);
|
|
85
|
+
};
|
|
86
|
+
const open = (task) => {
|
|
87
|
+
if (task)
|
|
88
|
+
go({ type: 'push', route: { name: 'task', ref: task.ref } });
|
|
89
|
+
};
|
|
90
|
+
useKeys(Layer.screen, (input, key) => {
|
|
91
|
+
if (nav.handleKey(input, key))
|
|
92
|
+
return true;
|
|
93
|
+
if (key.ctrl || key.meta)
|
|
94
|
+
return false;
|
|
95
|
+
// Archived is not a tab: moving right from it lands on the first tab, left on the last.
|
|
96
|
+
const tabIndex = TASK_FILTERS.indexOf(filter);
|
|
97
|
+
if ((key.tab && !key.shift) || input === 'l' || key.rightArrow)
|
|
98
|
+
setFilterAt(tabIndex + 1);
|
|
99
|
+
else if ((key.tab && key.shift) || input === 'h' || key.leftArrow)
|
|
100
|
+
setFilterAt(tabIndex === -1 ? TASK_FILTERS.length - 1 : tabIndex - 1);
|
|
101
|
+
else if (/^[1-7]$/.test(input))
|
|
102
|
+
setFilterAt(Number(input) - 1);
|
|
103
|
+
else if (key.return)
|
|
104
|
+
open(selected);
|
|
105
|
+
else if (input === 's' && selected)
|
|
106
|
+
actions.status(selected);
|
|
107
|
+
else if (input === 'a' && selected)
|
|
108
|
+
void actions.assign(selected);
|
|
109
|
+
else if (input === 'x' && selected)
|
|
110
|
+
void actions.toggleDone(selected);
|
|
111
|
+
else
|
|
112
|
+
return false;
|
|
113
|
+
return true;
|
|
114
|
+
}, active);
|
|
115
|
+
const counts = countStatuses(list);
|
|
116
|
+
const compactTabs = width < 96;
|
|
117
|
+
const title = project.data ? `${project.data.name} · ${project.data.key}` : HEADING[filter];
|
|
118
|
+
return (_jsxs(ScreenFrame, { title: title, aside: summaryText(filter, counts, list.length), hints: [
|
|
119
|
+
['↑↓', 'move'],
|
|
120
|
+
['enter', 'open'],
|
|
121
|
+
['n', 'new'],
|
|
122
|
+
['s', 'status'],
|
|
123
|
+
['x', 'done'],
|
|
124
|
+
['←→', 'filter'],
|
|
125
|
+
['/', 'search'],
|
|
126
|
+
[':', 'commands'],
|
|
127
|
+
['?', 'help'],
|
|
128
|
+
], children: [project.data ? _jsx(ProjectLine, { project: project.data }) : null, _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Tabs, { items: TASK_FILTERS.map((value, index) => ({ value, label: TAB_LABELS[value], shortcut: String(index + 1) })), value: TASK_FILTERS.includes(filter) ? filter : null, onChange: pick, active: active, compact: compactTabs }), filter === 'archived' ? _jsx(Text, { color: palette.warning, children: 'archived tasks' }) : null] }), _jsx(Box, { marginTop: 1, flexDirection: "column", height: tableHeight, children: tasks.error ? (_jsx(EmptyState, { lines: [tasks.error.message, tasks.error.hint ?? ''], icon: symbols.cross })) : list.length === 0 && !tasks.loading ? (_jsx(EmptyState, { lines: EMPTY_TASKS[filter], icon: symbols.check })) : (_jsx(TaskTable, { tasks: list, selected: nav.index, offset: nav.offset, height: tableHeight, width: width, showAssignee: filter !== 'mine', pullRequests: pullRequests.data, onSelect: (index) => nav.select(index), onOpen: (index) => open(list[index]), onScroll: (delta) => nav.select(nav.index + delta), active: active })) }), withPreview && selected ? _jsx(TaskPreview, { task: selected, pr: pullRequests.data?.get(selected.id), width: width, remark: summaryRemark(counts) }) : null, showMascot ? _jsx(Mascot, { scene: sceneForTaskList(list, filter, syncStatus?.syncing ?? false), paused: !active }) : null] }));
|
|
129
|
+
}
|
|
130
|
+
function summaryText(filter, counts, total) {
|
|
131
|
+
if (total === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (filter === 'done' || filter === 'archived')
|
|
134
|
+
return `${total} ${filter}`;
|
|
135
|
+
return `${activeCount(counts)} active ${symbols.dot} ${counts.in_progress} in progress ${symbols.dot} ${counts.review} review ${symbols.dot} ${counts.blocked} blocked`;
|
|
136
|
+
}
|
|
137
|
+
/** The selected task at a glance, so most of the time you do not need to open it. */
|
|
138
|
+
function TaskPreview({ task, pr, width, remark }) {
|
|
139
|
+
const description = task.description ? clampLines(wrapText(task.description, width - 4), 2) : [];
|
|
140
|
+
const meta = [
|
|
141
|
+
TYPE_LABELS[task.type],
|
|
142
|
+
task.project?.name,
|
|
143
|
+
task.assignee ? `@${task.assignee.username}` : 'unassigned',
|
|
144
|
+
task.requester ? `for ${task.requester}` : null,
|
|
145
|
+
`updated ${formatRelative(task.updatedAt)}`,
|
|
146
|
+
].filter(Boolean);
|
|
147
|
+
return (_jsx(Box, { marginTop: 1, children: _jsxs(Panel, { title: task.ref, aside: remark ?? undefined, height: PREVIEW_ROWS - 1, flexGrow: 1, children: [_jsx(Text, { wrap: "truncate-end", color: palette.text, bold: true, children: task.title }), _jsxs(Box, { gap: 2, children: [_jsx(StatusBadge, { status: task.status }), _jsx(PriorityMeter, { priority: task.priority }), _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: meta.join(` ${symbols.dot} `) })] }), pr ? (_jsx(PullRequestSummary, { pr: pr })) : task.branch ? (_jsx(Text, { color: palette.muted, wrap: "truncate-end", children: `${symbols.link} ${task.branch}` })) : (_jsx(Text, { color: palette.faint, children: 'no branch yet · b in the task starts one' })), description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.muted, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: 'No description.' }))] }) }));
|
|
148
|
+
}
|
|
149
|
+
function ProjectLine({ project }) {
|
|
150
|
+
const location = project.repositoryPath ?? project.repositoryUrl;
|
|
151
|
+
return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: [project.description, `${project.active} active`, `${project.counts.done} done`, location].filter(Boolean).join(` ${symbols.dot} `) }) }));
|
|
152
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { PullRequestSummary } from '../components/PullRequestLabel.js';
|
|
5
|
+
import { isClosed, isProvisional, TYPE_LABELS } from '../../domain/task.js';
|
|
6
|
+
import { formatRelative, formatStamp } from '../../utils/time.js';
|
|
7
|
+
import { clampLines, wrapText } from '../../utils/text.js';
|
|
8
|
+
import { useAppState } from '../app-state.js';
|
|
9
|
+
import { EmptyState } from '../components/EmptyState.js';
|
|
10
|
+
import { PriorityMeter, StatusBadge } from '../components/Labels.js';
|
|
11
|
+
import { Badge } from '../kit/Badge.js';
|
|
12
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
13
|
+
import { Panel } from '../kit/Panel.js';
|
|
14
|
+
import { Spinner } from '../kit/Spinner.js';
|
|
15
|
+
import { useLayout } from '../hooks/use-layout.js';
|
|
16
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
17
|
+
import { useTaskActions } from '../hooks/use-task-actions.js';
|
|
18
|
+
import { Layer } from '../input/dispatcher.js';
|
|
19
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
20
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
21
|
+
import { ScreenFrame } from './ScreenFrame.js';
|
|
22
|
+
import { Mascot } from '../mascot/Mascot.js';
|
|
23
|
+
import { sceneForTask } from '../mascot/selection.js';
|
|
24
|
+
const LABEL_WIDTH = 14;
|
|
25
|
+
export function TaskScreen({ active, taskRef }) {
|
|
26
|
+
const { services, session, cwd, detectMerges, followPullRequests, openOverlay, run, syncStatus } = useAppState();
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
void detectMerges(taskRef);
|
|
29
|
+
void followPullRequests(taskRef);
|
|
30
|
+
}, [detectMerges, followPullRequests, taskRef]);
|
|
31
|
+
const { width, height } = useLayout();
|
|
32
|
+
const actions = useTaskActions();
|
|
33
|
+
const [scroll, setScroll] = useState(0);
|
|
34
|
+
const [taskTarget, setTaskTarget] = useState(null);
|
|
35
|
+
const taskTopic = `task:${session.workspace.id}:${taskRef}`;
|
|
36
|
+
const query = useQuery(() => services.tasks.get(session, taskTarget ?? taskRef), `${taskTopic}:${taskTarget?.id ?? ''}`, [taskTopic]);
|
|
37
|
+
if (query.data && query.data.id !== taskTarget?.id) {
|
|
38
|
+
setTaskTarget({ id: query.data.id, number: query.data.number });
|
|
39
|
+
}
|
|
40
|
+
const task = query.data;
|
|
41
|
+
// Remote mode: conflicts and rejections the last syncs reported for this task.
|
|
42
|
+
const notices = useQuery(async () => (services.sync && query.data ? services.sync.notices(session.workspace.id, query.data.id) : []), `notices:${taskRef}:${query.data?.id ?? ''}`, [taskTopic]);
|
|
43
|
+
const openNotices = () => {
|
|
44
|
+
const sync = services.sync;
|
|
45
|
+
const list = notices.data ?? [];
|
|
46
|
+
if (!sync || list.length === 0 || !task)
|
|
47
|
+
return;
|
|
48
|
+
openOverlay({
|
|
49
|
+
kind: 'picker',
|
|
50
|
+
title: 'Sync notices',
|
|
51
|
+
context: task.ref,
|
|
52
|
+
options: list.flatMap((notice) => [
|
|
53
|
+
...(notice.kind === 'conflict' && notice.field
|
|
54
|
+
? [{ value: `restore:${notice.id}`, label: `Restore ${notice.field} to ${JSON.stringify(notice.overwritten)}`, hint: 'their value' }]
|
|
55
|
+
: []),
|
|
56
|
+
{ value: `dismiss:${notice.id}`, label: `Dismiss: ${notice.message}`, dim: true },
|
|
57
|
+
]),
|
|
58
|
+
onSelect: (value) => {
|
|
59
|
+
const [action, id] = value.split(':');
|
|
60
|
+
const notice = list.find((candidate) => candidate.id === id);
|
|
61
|
+
if (!notice)
|
|
62
|
+
return;
|
|
63
|
+
return run(async () => {
|
|
64
|
+
if (action === 'restore' && notice.field) {
|
|
65
|
+
await services.tasks.update(session, task, { [notice.field]: notice.overwritten });
|
|
66
|
+
}
|
|
67
|
+
await sync.dismissNotice(notice.id);
|
|
68
|
+
}, action === 'restore' ? `${task.ref}: ${notice.field} restored` : 'Notice dismissed');
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
// Remote mode: the latest chat messages that name this task.
|
|
73
|
+
const chatMentions = useQuery(async () => (services.chat && query.data && !isProvisional(query.data.number) ? services.chat.mentionsOfTask(session, query.data.number, 3) : []), `task-chat:${taskRef}:${query.data?.number ?? ''}`, [taskTopic, `chat:${session.workspace.id}`]);
|
|
74
|
+
const mentionList = chatMentions.data ?? [];
|
|
75
|
+
// The pull request comes from GitHub (gh), so it loads on its own too.
|
|
76
|
+
const pullRequest = useQuery(async () => (query.data?.branch ? services.git.pullRequest(session, taskRef, cwd) : null), `pr:${session.workspace.id}:${taskRef}:${query.data?.branch ?? ''}`);
|
|
77
|
+
// Git state loads on its own so the task shows instantly even in big repositories.
|
|
78
|
+
const git = useQuery(() => services.git.inspect(session, taskRef, cwd), `git:${session.workspace.id}:${taskRef}`);
|
|
79
|
+
// Layout: header, title, chips and notices; then Details + Description; the Activity panel gets the rest.
|
|
80
|
+
// Details and Description side by side only when both keep a useful width.
|
|
81
|
+
const wide = width >= 110;
|
|
82
|
+
const showMascot = width >= 80 && height >= 34 && Boolean(task);
|
|
83
|
+
const mascotRows = showMascot ? 15 : 0;
|
|
84
|
+
const titleLines = task ? clampLines(wrapText(task.title, width), 2) : [''];
|
|
85
|
+
const noticeLines = Math.min(2, notices.data?.length ?? 0);
|
|
86
|
+
const detailsHeight = 8;
|
|
87
|
+
// Title, chips (with a gap) and notices; then the details panel (with a gap).
|
|
88
|
+
const headRows = titleLines.length + 2 + noticeLines + 1 + detailsHeight;
|
|
89
|
+
// Stacked (narrow) layouts give the description what is left above the activity's minimum (2 rows + borders);
|
|
90
|
+
// with no room it is hidden (d still edits it).
|
|
91
|
+
const stackedDescription = Math.min(5, height - headRows - 4 - mascotRows);
|
|
92
|
+
const descriptionHeight = wide ? detailsHeight : stackedDescription >= 3 ? stackedDescription : 0;
|
|
93
|
+
const upperRows = headRows + (wide ? 0 : descriptionHeight);
|
|
94
|
+
const timelineRows = Math.max(2, height - upperRows - 2 - mascotRows);
|
|
95
|
+
const showMentions = wide && mentionList.length > 0;
|
|
96
|
+
const activityWidth = showMentions ? Math.floor(width * 0.62) - 4 : width - 4;
|
|
97
|
+
const timeline = task ? timelineLines(task.timeline, activityWidth) : [];
|
|
98
|
+
const actorWidth = Math.min(18, Math.max(8, ...timeline.map((line) => line.actor.length)) + 2);
|
|
99
|
+
const maxScroll = Math.max(0, timeline.length - timelineRows);
|
|
100
|
+
const offset = Math.min(scroll, maxScroll);
|
|
101
|
+
const scrollBy = (delta) => setScroll(Math.max(0, Math.min(maxScroll, offset + delta)));
|
|
102
|
+
useKeys(Layer.screen, (input, key) => {
|
|
103
|
+
if (!task || key.ctrl || key.meta)
|
|
104
|
+
return false;
|
|
105
|
+
const bindings = {
|
|
106
|
+
s: () => actions.status(task),
|
|
107
|
+
p: () => actions.priority(task),
|
|
108
|
+
a: () => actions.assign(task),
|
|
109
|
+
t: () => actions.type(task),
|
|
110
|
+
m: () => actions.project(task),
|
|
111
|
+
r: () => actions.requester(task),
|
|
112
|
+
d: () => actions.description(task),
|
|
113
|
+
c: () => actions.comment(task),
|
|
114
|
+
e: () => actions.edit(task),
|
|
115
|
+
x: () => actions.toggleDone(task),
|
|
116
|
+
'!': openNotices,
|
|
117
|
+
b: () => actions.branch(task),
|
|
118
|
+
g: () => actions.gitMenu(task),
|
|
119
|
+
C: () => actions.commit(task),
|
|
120
|
+
};
|
|
121
|
+
const action = bindings[input];
|
|
122
|
+
if (action)
|
|
123
|
+
void action();
|
|
124
|
+
// Timeline scrolls from the newest entries (bottom) upwards.
|
|
125
|
+
else if (input === 'k' || key.upArrow)
|
|
126
|
+
setScroll(Math.min(maxScroll, offset + 1));
|
|
127
|
+
else if (input === 'j' || key.downArrow)
|
|
128
|
+
setScroll(Math.max(0, offset - 1));
|
|
129
|
+
else
|
|
130
|
+
return false;
|
|
131
|
+
return true;
|
|
132
|
+
}, active);
|
|
133
|
+
const noticeCount = notices.data?.length ?? 0;
|
|
134
|
+
const hints = [
|
|
135
|
+
...(noticeCount ? [['!', `${noticeCount} sync notice${noticeCount === 1 ? '' : 's'}`]] : []),
|
|
136
|
+
['s', 'status'],
|
|
137
|
+
['p', 'priority'],
|
|
138
|
+
['a', 'assign'],
|
|
139
|
+
['c', 'comment'],
|
|
140
|
+
['e', 'edit'],
|
|
141
|
+
['g', 'git'],
|
|
142
|
+
['x', task?.status === 'done' ? 'reopen' : 'done'],
|
|
143
|
+
['esc', 'back'],
|
|
144
|
+
];
|
|
145
|
+
if (!task) {
|
|
146
|
+
return (_jsxs(ScreenFrame, { title: taskRef, hints: [['esc', 'back']], children: [query.error ? _jsx(EmptyState, { lines: [query.error.message, query.error.hint ?? ''], icon: symbols.cross }) : _jsx(Spinner, { label: "Loading\u2026" }), width >= 80 && height >= 20 ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : query.loading ? 'clockIn' : 'bugHunt', paused: !active }) : null] }));
|
|
147
|
+
}
|
|
148
|
+
const end = timeline.length - offset;
|
|
149
|
+
const visibleTimeline = timeline.slice(Math.max(0, end - timelineRows), end);
|
|
150
|
+
const description = task.description ? clampLines(wrapText(task.description, (wide ? width - Math.floor(width / 2) : width) - 5), Math.max(1, descriptionHeight - 2)) : [];
|
|
151
|
+
const detailsWidth = wide ? Math.floor(width / 2) : width;
|
|
152
|
+
return (_jsxs(ScreenFrame, { title: `${task.ref} · ${TYPE_LABELS[task.type]}`, aside: width >= 70 ? `created ${formatStamp(task.createdAt)}${task.creator ? ` by @${task.creator.username}` : ''} ${symbols.dot} updated ${formatRelative(task.updatedAt)}` : undefined, hints: hints, children: [titleLines.map((line, index) => (_jsx(Text, { bold: true, color: palette.text, children: line }, index))), _jsxs(Box, { gap: 2, marginTop: 1, children: [_jsx(Clickable, { onClick: () => actions.status(task), active: active, children: _jsx(StatusBadge, { status: task.status }) }), _jsx(Clickable, { onClick: () => actions.priority(task), active: active, children: _jsx(PriorityMeter, { priority: task.priority }) }), task.archivedAt ? _jsx(Badge, { tone: "warning", children: `archived ${formatRelative(task.archivedAt)} · e to restore` }) : null, isClosed(task.status) && task.completedAt ? _jsx(Text, { color: palette.muted, children: `closed ${formatRelative(task.completedAt)}` }) : null] }), (notices.data ?? []).slice(0, 2).map((notice) => (_jsx(Clickable, { onClick: openNotices, active: active, children: _jsx(Text, { color: notice.kind === 'conflict' ? palette.warning : palette.danger, wrap: "truncate-end", children: `! ${notice.message}` }) }, notice.id))), _jsxs(Box, { marginTop: 1, flexDirection: wide ? 'row' : 'column', gap: wide ? 1 : 0, children: [_jsxs(Panel, { title: "Details", width: detailsWidth, height: detailsHeight, children: [_jsx(Field, { label: "Project", value: task.project?.name, onClick: () => actions.project(task), active: active }), _jsx(Field, { label: "Assignee", value: task.assignee ? `@${task.assignee.username}` : undefined, hint: task.assignee?.displayName, onClick: () => actions.assign(task), active: active }), _jsx(Field, { label: "Requested by", value: task.requester ?? undefined, onClick: () => actions.requester(task), active: active }), _jsx(GitFields, { state: git.data, fallbackBranch: task.branch ?? task.suggestedBranch, recorded: task.branch !== null, closed: isClosed(task.status), onBranch: () => actions.branch(task), onCommits: () => openOverlay({ kind: 'git-log' }), active: active }), _jsx(PullRequestField, { state: task.branch ? pullRequest.data : null, loading: pullRequest.loading, onClick: () => actions.gitMenu(task), active: active })] }), descriptionHeight ? (_jsx(Panel, { title: "Description", aside: description.length ? 'd edit' : undefined, height: descriptionHeight, flexGrow: 1, children: _jsx(Clickable, { onClick: () => void actions.description(task), active: active, flexDirection: "column", children: description.length ? (description.map((line, index) => (_jsx(Text, { color: palette.text, children: line }, index)))) : (_jsx(Text, { color: palette.faint, children: "No description. Press d (or click) to write one." })) }) })) : null] }), _jsxs(Box, { flexGrow: 1, gap: 1, children: [_jsx(Panel, { title: "Activity", aside: maxScroll > 0 ? (offset > 0 ? '↓ newer' : '↑ older') : undefined, height: timelineRows + 2, flexGrow: 1, children: _jsxs(Clickable, { onWheel: (direction) => scrollBy(-direction), active: active, flexDirection: "column", children: [visibleTimeline.map((line) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.faint, children: line.stamp.padEnd(7) }), _jsx(Text, { color: line.actor ? palette.muted : palette.faint, children: line.actor.padEnd(actorWidth) }), line.kind === 'comment' ? _jsx(Text, { color: palette.accent, children: line.first ? `${symbols.comment} ` : ' ' }) : _jsx(Text, { color: palette.faint, children: '• ' }), _jsx(Text, { color: line.kind === 'event' ? palette.muted : palette.text, children: line.text })] }, line.key))), visibleTimeline.length === 0 ? _jsx(Text, { color: palette.faint, children: "Nothing yet." }) : null] }) }), showMentions ? (_jsx(Panel, { title: "Mentioned in chat", height: timelineRows + 2, width: Math.floor(width * 0.38), children: mentionList.map((message) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.muted, children: `${message.author ? `@${message.author.username}` : 'someone'} ` }), _jsx(Text, { color: palette.faint, children: formatStamp(message.createdAt) })] }), _jsx(Text, { color: palette.text, wrap: "truncate-end", children: message.body.split('\n')[0] })] }, message.id))) })) : null] }), showMascot && task ? _jsx(Mascot, { scene: syncStatus?.syncing ? 'syncing' : sceneForTask(task), paused: !active }) : null] }));
|
|
153
|
+
}
|
|
154
|
+
function PullRequestField({ state, loading, onClick, active }) {
|
|
155
|
+
return (_jsxs(FieldRow, { onClick: onClick, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: "Pull request" }) }), state?.status === 'found' ? (_jsx(PullRequestSummary, { pr: state.pr })) : state?.status === 'unavailable' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${state.reason}${state.hint ? ` ${state.hint}` : ''}` })) : state?.status === 'none' ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `none · g to open one` })) : (loading && state === undefined ? _jsx(Spinner, { label: "checking GitHub\u2026", color: palette.faint }) : _jsx(Text, { color: palette.faint, children: "\u2014" }))] }));
|
|
156
|
+
}
|
|
157
|
+
/** A row of the Details panel: clicking it opens what changes it. */
|
|
158
|
+
function FieldRow({ onClick, active, children }) {
|
|
159
|
+
return (_jsx(Clickable, { onClick: onClick, active: active, children: _jsx(Box, { flexGrow: 1, children: children }) }));
|
|
160
|
+
}
|
|
161
|
+
function Field({ label, value, hint, onClick, active = true, }) {
|
|
162
|
+
return (_jsxs(FieldRow, { onClick: onClick, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: label }) }), _jsxs(Text, { wrap: "truncate-end", children: [value ? _jsx(Text, { color: palette.text, children: value }) : _jsx(Text, { color: palette.faint, children: "\u2014" }), hint ? _jsx(Text, { color: palette.muted, children: value ? ` ${hint}` : ` suggested ${hint}` }) : null] })] }));
|
|
163
|
+
}
|
|
164
|
+
/** Branch and commits rows. Two rows always, so the layout does not jump while Git loads. */
|
|
165
|
+
function GitFields({ state, fallbackBranch, recorded, closed, onBranch, onCommits, active, }) {
|
|
166
|
+
if (!state) {
|
|
167
|
+
return (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Branch", value: recorded ? fallbackBranch : undefined, hint: recorded ? undefined : fallbackBranch, onClick: onBranch, active: active }), _jsxs(FieldRow, { active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Commits" }) }), _jsx(Spinner, { label: "reading Git\u2026", color: palette.faint })] })] }));
|
|
168
|
+
}
|
|
169
|
+
if (state.status === 'unavailable') {
|
|
170
|
+
return (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Branch", value: recorded ? fallbackBranch : undefined, hint: recorded ? undefined : fallbackBranch, onClick: onBranch, active: active }), _jsxs(FieldRow, { onClick: onBranch, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Git" }) }), _jsx(Text, { color: palette.faint, wrap: "truncate-end", children: state.reason })] })] }));
|
|
171
|
+
}
|
|
172
|
+
const [latest] = state.commits;
|
|
173
|
+
return (_jsxs(_Fragment, { children: [_jsxs(FieldRow, { onClick: onBranch, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Branch" }) }), _jsxs(Text, { wrap: "truncate-end", children: [state.checkedOut ? (_jsx(Text, { color: palette.success, children: `${symbols.active} checked out ` })) : state.branchExists ? (_jsx(Text, { color: palette.muted, children: 'not checked out ' })) : state.recorded && !closed ? (_jsx(Text, { color: palette.warning, children: 'deleted, no merge found · b recreate · g forget ' })) : state.recorded ? (_jsx(Text, { color: palette.muted, children: 'deleted ' })) : closed ? null : (_jsx(Text, { color: palette.muted, children: 'suggested · b to start ' })), state.uncommitted > 0 ? _jsx(Text, { color: palette.warning, children: `${state.uncommitted} uncommitted ` }) : null, _jsx(Text, { color: state.branchExists ? palette.text : palette.faint, children: closed && !state.recorded && !state.branchExists ? '—' : state.branch })] })] }), _jsxs(FieldRow, { onClick: onCommits, active: active, children: [_jsx(Box, { width: LABEL_WIDTH, flexShrink: 0, children: _jsx(Text, { color: palette.muted, children: "Commits" }) }), latest ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: palette.warning, children: latest.shortHash }), _jsx(Text, { children: ` ${latest.subject}` }), _jsx(Text, { dimColor: true, children: ` ${formatRelative(latest.date)}${state.commits.length > 1 ? ` +${state.commits.length - 1} more` : ''}` })] })) : (_jsx(Text, { color: palette.faint, children: "none yet" }))] })] }));
|
|
174
|
+
}
|
|
175
|
+
/** Flattens the timeline into display rows; comments wrap to at most three lines. */
|
|
176
|
+
function timelineLines(entries, width) {
|
|
177
|
+
const textWidth = Math.max(10, width - 7 - 18 - 2);
|
|
178
|
+
const now = new Date();
|
|
179
|
+
return entries.flatMap((entry) => {
|
|
180
|
+
const stamp = formatStamp(entry.at, now);
|
|
181
|
+
const actor = entry.actor ? `@${entry.actor.username}` : '';
|
|
182
|
+
if (entry.kind === 'event')
|
|
183
|
+
return [{ key: entry.id, kind: entry.kind, stamp, actor, text: entry.text, first: true }];
|
|
184
|
+
return clampLines(wrapText(entry.body, textWidth), 3).map((text, index) => ({
|
|
185
|
+
key: `${entry.id}:${index}`,
|
|
186
|
+
kind: entry.kind,
|
|
187
|
+
stamp: index === 0 ? stamp : '',
|
|
188
|
+
actor: index === 0 ? actor : '',
|
|
189
|
+
text,
|
|
190
|
+
first: index === 0,
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
}
|