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,117 @@
|
|
|
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 { TASK_PRIORITIES, TASK_TYPES } from '../../domain/task.js';
|
|
5
|
+
import { useAppState } from '../app-state.js';
|
|
6
|
+
import { TextField } from '../components/TextField.js';
|
|
7
|
+
import { Button } from '../kit/Button.js';
|
|
8
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
9
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
10
|
+
import { Layer } from '../input/dispatcher.js';
|
|
11
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
12
|
+
import { palette, symbols, toneColors } from '../theme/theme.js';
|
|
13
|
+
import { memberOptions, priorityOptions, projectOptions, NONE, typeOptions, fromOption } from './options.js';
|
|
14
|
+
import { OverlayFrame } from './OverlayFrame.js';
|
|
15
|
+
import { completeFrom } from './PromptOverlay.js';
|
|
16
|
+
const FIELDS = ['title', 'project', 'type', 'priority', 'assignee', 'requester'];
|
|
17
|
+
const LABELS = {
|
|
18
|
+
title: 'Title',
|
|
19
|
+
project: 'Project',
|
|
20
|
+
type: 'Type',
|
|
21
|
+
priority: 'Priority',
|
|
22
|
+
assignee: 'Assignee',
|
|
23
|
+
requester: 'Requested by',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Quick capture. Only the title is required and Enter creates from any
|
|
27
|
+
* field, so "type title, Enter" is the whole flow when defaults are fine.
|
|
28
|
+
*/
|
|
29
|
+
export function NewTaskOverlay({ spec }) {
|
|
30
|
+
const { services, session, closeOverlay, run } = useAppState();
|
|
31
|
+
const context = useQuery(async () => {
|
|
32
|
+
const [projects, members, requesters] = await Promise.all([
|
|
33
|
+
services.projects.list(session),
|
|
34
|
+
services.workspaces.members(session),
|
|
35
|
+
services.tasks.knownRequesters(session),
|
|
36
|
+
]);
|
|
37
|
+
return { projects: projectOptions(projects), members: memberOptions(members, session.user.id), requesters };
|
|
38
|
+
}, `new-task:${session.workspace.id}`);
|
|
39
|
+
const [focus, setFocus] = useState('title');
|
|
40
|
+
const [title, setTitle] = useState('');
|
|
41
|
+
const [requester, setRequester] = useState('');
|
|
42
|
+
const [projectId, setProjectId] = useState(spec.projectId ?? NONE);
|
|
43
|
+
const [type, setType] = useState('feature');
|
|
44
|
+
const [priority, setPriority] = useState('medium');
|
|
45
|
+
const [assigneeId, setAssigneeId] = useState(session.user.id);
|
|
46
|
+
const [busy, setBusy] = useState(false);
|
|
47
|
+
const projects = context.data?.projects ?? [];
|
|
48
|
+
const members = context.data?.members ?? [];
|
|
49
|
+
const requesters = context.data?.requesters ?? [];
|
|
50
|
+
const completion = focus === 'requester' ? completeFrom(requester, requesters) : null;
|
|
51
|
+
const create = async () => {
|
|
52
|
+
if (busy)
|
|
53
|
+
return;
|
|
54
|
+
setBusy(true);
|
|
55
|
+
const created = await run(() => services.tasks.create(session, {
|
|
56
|
+
title,
|
|
57
|
+
projectId: fromOption(projectId),
|
|
58
|
+
type,
|
|
59
|
+
priority,
|
|
60
|
+
assigneeId: fromOption(assigneeId),
|
|
61
|
+
requester,
|
|
62
|
+
}), `Task created: ${title.trim()}`);
|
|
63
|
+
setBusy(false);
|
|
64
|
+
if (created)
|
|
65
|
+
closeOverlay(spec);
|
|
66
|
+
};
|
|
67
|
+
const cycle = (values, current, step) => values[(values.indexOf(current) + step + values.length) % values.length] ?? current;
|
|
68
|
+
const cycleOption = (options, current, step) => cycle(options.map((option) => option.value), current, step);
|
|
69
|
+
const moveFocus = (direction) => setFocus(cycle(FIELDS, focus, direction));
|
|
70
|
+
const step = (field, direction) => {
|
|
71
|
+
if (field === 'project')
|
|
72
|
+
setProjectId(cycleOption(projects, projectId, direction));
|
|
73
|
+
if (field === 'type')
|
|
74
|
+
setType(cycle(TASK_TYPES, type, direction));
|
|
75
|
+
if (field === 'priority')
|
|
76
|
+
setPriority(cycle([...TASK_PRIORITIES].reverse(), priority, direction));
|
|
77
|
+
if (field === 'assignee')
|
|
78
|
+
setAssigneeId(cycleOption(members, assigneeId, direction));
|
|
79
|
+
};
|
|
80
|
+
useKeys(Layer.overlay, (input, key) => {
|
|
81
|
+
if (key.escape)
|
|
82
|
+
closeOverlay(spec);
|
|
83
|
+
else if (key.return)
|
|
84
|
+
void create();
|
|
85
|
+
else if (key.tab && completion)
|
|
86
|
+
setRequester(completion);
|
|
87
|
+
else if ((key.tab && !key.shift) || key.downArrow)
|
|
88
|
+
moveFocus(1);
|
|
89
|
+
else if ((key.tab && key.shift) || key.upArrow)
|
|
90
|
+
moveFocus(-1);
|
|
91
|
+
else if (focus !== 'title' && focus !== 'requester' && (key.leftArrow || key.rightArrow || input === ' ' || input === 'h' || input === 'l')) {
|
|
92
|
+
step(focus, key.leftArrow || input === 'h' ? -1 : 1);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
const option = (options, value) => options.find((candidate) => candidate.value === value);
|
|
97
|
+
/** `‹ Feature ›`: a value you change with ←/→ or by clicking it. */
|
|
98
|
+
const choice = (field, picked) => {
|
|
99
|
+
const focused = focus === field;
|
|
100
|
+
const color = picked?.tone ? toneColors(picked.tone).fg : picked?.dim ? palette.muted : palette.text;
|
|
101
|
+
return (_jsxs(Clickable, { onClick: () => (focused ? step(field, 1) : setFocus(field)), layer: Layer.overlay, children: [_jsxs(Text, { backgroundColor: focused ? palette.selection : palette.neutralSoft, children: [_jsx(Text, { color: focused ? palette.accent : palette.faint, children: ' ‹ ' }), _jsx(Text, { color: color, bold: focused, children: picked?.label ?? '—' }), _jsx(Text, { color: focused ? palette.accent : palette.faint, children: ' › ' })] }), picked?.hint && focused ? _jsx(Text, { color: palette.muted, children: ` ${picked.hint}` }) : null] }));
|
|
102
|
+
};
|
|
103
|
+
const rows = {
|
|
104
|
+
title: _jsx(TextField, { value: title, onChange: setTitle, active: focus === 'title', placeholder: "What needs to happen?", width: 46 }),
|
|
105
|
+
project: choice('project', option(projects, projectId)),
|
|
106
|
+
type: choice('type', option(typeOptions, type)),
|
|
107
|
+
priority: choice('priority', option(priorityOptions, priority)),
|
|
108
|
+
assignee: choice('assignee', option(members, assigneeId)),
|
|
109
|
+
requester: (_jsxs(Box, { children: [_jsx(TextField, { value: requester, onChange: setRequester, active: focus === 'requester', placeholder: requesters[0] ?? 'Marketing, Finance…', width: 30 }), completion ? _jsx(Text, { color: palette.muted, children: ` tab → ${completion}` }) : null] })),
|
|
110
|
+
};
|
|
111
|
+
return (_jsxs(OverlayFrame, { title: "New task", context: session.workspace.name, width: 70, hints: [
|
|
112
|
+
['enter', 'create'],
|
|
113
|
+
['tab ↓', 'next field'],
|
|
114
|
+
['←→', 'change'],
|
|
115
|
+
['esc', 'cancel'],
|
|
116
|
+
], children: [FIELDS.map((field) => (_jsx(Clickable, { onClick: () => setFocus(field), layer: Layer.overlay, children: _jsxs(Box, { marginBottom: field === 'title' ? 1 : 0, flexGrow: 1, children: [_jsx(Box, { width: 15, flexShrink: 0, children: _jsxs(Text, { color: focus === field ? palette.accent : palette.muted, bold: focus === field, children: [focus === field ? `${symbols.pointer} ` : ' ', LABELS[field]] }) }), rows[field]] }) }, field))), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", children: [_jsx(Text, { color: palette.faint, children: "Only the title is required." }), _jsxs(Box, { gap: 2, children: [_jsx(Button, { label: "Cancel", onPress: () => closeOverlay(spec) }), _jsx(Button, { label: "Create task", variant: "primary", onPress: () => void create() })] })] })] }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { useAppState } from '../app-state.js';
|
|
5
|
+
import { useMeasure } from '../hooks/use-measure.js';
|
|
6
|
+
import { useTerminalSize } from '../hooks/use-terminal-size.js';
|
|
7
|
+
import { useHints } from '../chrome/context.js';
|
|
8
|
+
import { Layer } from '../input/dispatcher.js';
|
|
9
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
10
|
+
import { Panel } from '../kit/Panel.js';
|
|
11
|
+
import { palette } from '../theme/theme.js';
|
|
12
|
+
/**
|
|
13
|
+
* A floating window over the screen, centered, on a raised surface. The
|
|
14
|
+
* screen stays visible behind it; clicking outside closes it, like Esc.
|
|
15
|
+
*/
|
|
16
|
+
export function OverlayFrame({ title, context, hints, width = 76, children }) {
|
|
17
|
+
useHints(hints);
|
|
18
|
+
const { closeOverlay } = useAppState();
|
|
19
|
+
const { columns, rows } = useTerminalSize();
|
|
20
|
+
const ref = useRef(null);
|
|
21
|
+
const { height } = useMeasure(ref);
|
|
22
|
+
const hasMeasured = height > 0;
|
|
23
|
+
const shown = Math.min(width, columns - 4);
|
|
24
|
+
const longContext = context !== undefined && context.length + title.length + 10 > shown;
|
|
25
|
+
const top = hasMeasured ? Math.max(1, Math.floor((rows - height) / 2)) : 2;
|
|
26
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { position: "absolute", top: 0, left: 0, width: columns, height: rows, children: _jsx(Clickable, { onClick: () => closeOverlay(), layer: Layer.overlay, width: columns, flexGrow: 1, children: _jsx(Box, { width: columns, height: rows }) }) }), _jsx(Box, { ref: ref, position: "absolute", top: top, left: Math.max(0, Math.floor((columns - shown) / 2)), width: shown, flexDirection: "column", children: _jsx(Clickable, { onClick: () => undefined, layer: Layer.overlay, flexDirection: "column", children: _jsx(Panel, { title: title, aside: longContext ? undefined : context, focused: true, backgroundColor: palette.surface, width: shown, children: _jsxs(Box, { flexDirection: "column", paddingY: 0, children: [longContext ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: palette.muted, children: context }) })) : null, children] }) }) }) })] }));
|
|
27
|
+
}
|
|
28
|
+
/** Rows a window can give its list or content (the window floats with a margin). */
|
|
29
|
+
export function useOverlayHeight() {
|
|
30
|
+
return Math.max(3, useTerminalSize().rows - 10);
|
|
31
|
+
}
|
|
32
|
+
/** Width available inside a window of `width` columns. */
|
|
33
|
+
export function useOverlayWidth(width = 76) {
|
|
34
|
+
return Math.max(20, Math.min(width, useTerminalSize().columns - 4) - 4);
|
|
35
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { useAppState } from '../app-state.js';
|
|
5
|
+
import { TextField } from '../components/TextField.js';
|
|
6
|
+
import { useList } from '../hooks/use-list.js';
|
|
7
|
+
import { Layer } from '../input/dispatcher.js';
|
|
8
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
9
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
10
|
+
import { ScrollBar } from '../kit/ScrollBar.js';
|
|
11
|
+
import { palette, symbols, toneColors } from '../theme/theme.js';
|
|
12
|
+
import { OverlayFrame, useOverlayHeight } from './OverlayFrame.js';
|
|
13
|
+
const CREATE = '__create__';
|
|
14
|
+
export function matchOptions(options, query) {
|
|
15
|
+
const needle = query.trim().toLowerCase();
|
|
16
|
+
if (!needle)
|
|
17
|
+
return [...options];
|
|
18
|
+
return options.filter((option) => `${option.label} ${option.hint ?? ''}`.toLowerCase().includes(needle));
|
|
19
|
+
}
|
|
20
|
+
export function PickerOverlay({ spec }) {
|
|
21
|
+
const { closeOverlay } = useAppState();
|
|
22
|
+
const [query, setQuery] = useState('');
|
|
23
|
+
const [busy, setBusy] = useState(false);
|
|
24
|
+
const height = Math.min(14, useOverlayHeight() - (spec.filterable ? 3 : 0));
|
|
25
|
+
const matches = matchOptions(spec.options, query);
|
|
26
|
+
const canCreate = Boolean(spec.create && query.trim() && matches.length === 0);
|
|
27
|
+
const items = canCreate && spec.create ? [{ value: CREATE, label: spec.create.label(query.trim()) }] : matches;
|
|
28
|
+
const initialIndex = Math.max(0, spec.options.findIndex((option) => option.value === spec.initial));
|
|
29
|
+
const list = useList(items.length, height, !spec.filterable, initialIndex);
|
|
30
|
+
const choose = async (option) => {
|
|
31
|
+
if (!option || busy)
|
|
32
|
+
return;
|
|
33
|
+
setBusy(true);
|
|
34
|
+
const result = option.value === CREATE && spec.create ? await spec.create.onCreate(query.trim()) : await spec.onSelect(option.value);
|
|
35
|
+
setBusy(false);
|
|
36
|
+
if (result !== false)
|
|
37
|
+
closeOverlay(spec);
|
|
38
|
+
};
|
|
39
|
+
useKeys(Layer.overlay, (input, key) => {
|
|
40
|
+
if (key.escape)
|
|
41
|
+
closeOverlay(spec);
|
|
42
|
+
else if (key.return)
|
|
43
|
+
void choose(items[list.index]);
|
|
44
|
+
else if (list.handleKey(input, key))
|
|
45
|
+
return true;
|
|
46
|
+
else if (!spec.filterable && /^[1-9]$/.test(input))
|
|
47
|
+
void choose(items[Number(input) - 1]);
|
|
48
|
+
return true; // modal: nothing below reacts while a picker is open
|
|
49
|
+
});
|
|
50
|
+
const visible = items.slice(list.offset, list.offset + height);
|
|
51
|
+
const labelWidth = Math.max(8, ...items.map((option) => option.label.length)) + 2;
|
|
52
|
+
const hintWidth = Math.max(0, ...items.map((option) => option.hint?.length ?? 0));
|
|
53
|
+
// Wide enough for the options and, when possible, for the title and context on one line.
|
|
54
|
+
const width = Math.min(96, Math.max(44, labelWidth + hintWidth + 14, (spec.context?.length ?? 0) + spec.title.length + 12));
|
|
55
|
+
return (_jsxs(OverlayFrame, { title: spec.title, context: spec.context, width: width, hints: [
|
|
56
|
+
['↑↓', 'move'],
|
|
57
|
+
['enter', 'select'],
|
|
58
|
+
...(spec.filterable ? [] : [['1-9', 'pick']]),
|
|
59
|
+
['esc', 'cancel'],
|
|
60
|
+
], children: [spec.filterable ? (_jsx(Box, { marginBottom: 1, children: _jsx(TextField, { value: query, onChange: (next) => {
|
|
61
|
+
setQuery(next);
|
|
62
|
+
list.select(0);
|
|
63
|
+
}, placeholder: "Type to filter" }) })) : null, items.length === 0 ? _jsx(Text, { color: palette.faint, children: "No matches." }) : null, _jsxs(Box, { children: [_jsx(Clickable, { onWheel: (direction) => list.select(list.index + direction), layer: Layer.overlay, flexDirection: "column", flexGrow: 1, children: visible.map((option, position) => {
|
|
64
|
+
const index = list.offset + position;
|
|
65
|
+
const selected = index === list.index;
|
|
66
|
+
const current = option.value === spec.initial;
|
|
67
|
+
const bg = selected ? { backgroundColor: palette.selection } : {};
|
|
68
|
+
const tone = option.tone ? toneColors(option.tone).fg : undefined;
|
|
69
|
+
const color = option.value === CREATE ? palette.accent : option.dim ? palette.muted : (tone ?? palette.text);
|
|
70
|
+
return (_jsx(Clickable, { onClick: () => void choose(option), layer: Layer.overlay, children: _jsxs(Box, { flexGrow: 1, ...bg, children: [_jsx(Text, { color: palette.accent, ...bg, children: selected ? `${symbols.pointer} ` : ' ' }), !spec.filterable ? _jsx(Text, { color: palette.faint, ...bg, children: index < 9 ? `${index + 1} ` : ' ' }) : null, _jsx(Box, { width: labelWidth, flexShrink: 0, children: _jsx(Text, { color: color, bold: selected, ...bg, children: option.label }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, children: option.hint ? (_jsx(Text, { color: palette.muted, wrap: "truncate-middle", ...bg, children: option.hint })) : null }), current ? _jsx(Text, { color: palette.accent, ...bg, children: ` ${symbols.check} ` }) : null] }) }, option.value));
|
|
71
|
+
}) }), _jsx(ScrollBar, { total: items.length, visible: height, offset: list.offset, height: Math.min(height, items.length) })] })] }));
|
|
72
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { useAppState } from '../app-state.js';
|
|
5
|
+
import { TextField } from '../components/TextField.js';
|
|
6
|
+
import { palette } from '../theme/theme.js';
|
|
7
|
+
import { Layer } from '../input/dispatcher.js';
|
|
8
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
9
|
+
import { OverlayFrame } from './OverlayFrame.js';
|
|
10
|
+
export function completeFrom(value, suggestions) {
|
|
11
|
+
const needle = value.trim().toLowerCase();
|
|
12
|
+
if (!needle)
|
|
13
|
+
return null;
|
|
14
|
+
const match = suggestions.find((suggestion) => suggestion.toLowerCase().startsWith(needle));
|
|
15
|
+
return match && match !== value ? match : null;
|
|
16
|
+
}
|
|
17
|
+
export function PromptOverlay({ spec }) {
|
|
18
|
+
const { closeOverlay, notify } = useAppState();
|
|
19
|
+
const [value, setValue] = useState(spec.initial ?? '');
|
|
20
|
+
const [busy, setBusy] = useState(false);
|
|
21
|
+
const suggestions = spec.suggestions ?? [];
|
|
22
|
+
const completion = completeFrom(value, suggestions);
|
|
23
|
+
const submit = async () => {
|
|
24
|
+
if (busy)
|
|
25
|
+
return;
|
|
26
|
+
if (!spec.allowEmpty && !value.trim()) {
|
|
27
|
+
notify('Type something first.', 'error');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
setBusy(true);
|
|
31
|
+
const result = await spec.onSubmit(value);
|
|
32
|
+
setBusy(false);
|
|
33
|
+
if (result !== false)
|
|
34
|
+
closeOverlay(spec);
|
|
35
|
+
};
|
|
36
|
+
useKeys(Layer.overlay, (_input, key) => {
|
|
37
|
+
if (key.escape)
|
|
38
|
+
closeOverlay(spec);
|
|
39
|
+
else if (key.return)
|
|
40
|
+
void submit();
|
|
41
|
+
else if (key.tab && completion)
|
|
42
|
+
setValue(completion);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
const others = suggestions.filter((suggestion) => suggestion !== value).slice(0, 6);
|
|
46
|
+
return (_jsxs(OverlayFrame, { title: spec.title, context: spec.context, hints: [
|
|
47
|
+
['enter', 'save'],
|
|
48
|
+
...(suggestions.length ? [['tab', 'complete']] : []),
|
|
49
|
+
['esc', 'cancel'],
|
|
50
|
+
], children: [_jsx(TextField, { value: value, onChange: setValue, placeholder: spec.placeholder }), completion ? (_jsx(Text, { color: palette.muted, children: `tab → ${completion}` })) : others.length ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.faint, wrap: "truncate-end", children: `known: ${others.join(', ')}` }) })) : null] }));
|
|
51
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { useAppState } from '../app-state.js';
|
|
5
|
+
import { EmptyState } from '../components/EmptyState.js';
|
|
6
|
+
import { TaskTable, tableRows } from '../components/TaskTable.js';
|
|
7
|
+
import { TextField } from '../components/TextField.js';
|
|
8
|
+
import { palette } from '../theme/theme.js';
|
|
9
|
+
import { EMPTY_SEARCH } from '../copy.js';
|
|
10
|
+
import { useList } from '../hooks/use-list.js';
|
|
11
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
12
|
+
import { Layer } from '../input/dispatcher.js';
|
|
13
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
14
|
+
import { OverlayFrame, useOverlayHeight, useOverlayWidth } from './OverlayFrame.js';
|
|
15
|
+
const WIDTH = 104;
|
|
16
|
+
/** Live search by title or ID. Results update on every keystroke. */
|
|
17
|
+
export function SearchOverlay({ spec }) {
|
|
18
|
+
const { services, session, closeOverlay, go } = useAppState();
|
|
19
|
+
const width = useOverlayWidth(WIDTH);
|
|
20
|
+
const [query, setQuery] = useState('');
|
|
21
|
+
const results = useQuery(() => services.tasks.search(session, query, 100), `search:${session.workspace.id}:${query}`);
|
|
22
|
+
const tasks = results.data ?? [];
|
|
23
|
+
const tableHeight = Math.min(16, useOverlayHeight() - 3);
|
|
24
|
+
const open = (index) => {
|
|
25
|
+
const task = tasks[index];
|
|
26
|
+
if (!task)
|
|
27
|
+
return;
|
|
28
|
+
closeOverlay(spec);
|
|
29
|
+
go({ type: 'push', route: { name: 'task', ref: task.ref } });
|
|
30
|
+
};
|
|
31
|
+
const list = useList(tasks.length, tableRows(tableHeight), false);
|
|
32
|
+
useKeys(Layer.overlay, (input, key) => {
|
|
33
|
+
if (key.escape)
|
|
34
|
+
closeOverlay(spec);
|
|
35
|
+
else if (key.return)
|
|
36
|
+
open(list.index);
|
|
37
|
+
else
|
|
38
|
+
list.handleKey(input, key);
|
|
39
|
+
return true;
|
|
40
|
+
});
|
|
41
|
+
return (_jsxs(OverlayFrame, { title: "Search", context: "title or ID, closed and archived too", width: WIDTH, hints: [
|
|
42
|
+
['↑/↓', 'move'],
|
|
43
|
+
['enter', 'open'],
|
|
44
|
+
['esc', 'close'],
|
|
45
|
+
], children: [_jsx(Box, { marginBottom: 1, children: _jsx(TextField, { value: query, onChange: (next) => {
|
|
46
|
+
setQuery(next);
|
|
47
|
+
list.select(0);
|
|
48
|
+
}, placeholder: "stripe, SOJA-12\u2026" }) }), !query.trim() ? (_jsx(Text, { color: palette.faint, children: "Start typing: a word from the title, or a number like 12." })) : tasks.length === 0 && !results.loading ? (_jsx(EmptyState, { lines: EMPTY_SEARCH })) : (_jsx(TaskTable, { tasks: tasks, selected: list.index, offset: list.offset, height: tableHeight, width: width, layer: Layer.overlay, onSelect: (index) => list.select(index), onOpen: open, onScroll: (delta) => list.select(list.index + delta) }))] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TASK_PRIORITIES, TASK_STATUSES, TASK_TYPES, TYPE_LABELS } from '../../domain/task.js';
|
|
2
|
+
import { priorityStyles, priorityTone, statusStyles, statusTone } from '../theme/theme.js';
|
|
3
|
+
export const NONE = '__none__';
|
|
4
|
+
export const statusOptions = TASK_STATUSES.map((status) => {
|
|
5
|
+
const style = statusStyles[status];
|
|
6
|
+
return { value: status, label: `${style.glyph} ${style.label}`, tone: statusTone[status], dim: status === 'cancelled' };
|
|
7
|
+
});
|
|
8
|
+
export const priorityOptions = [...TASK_PRIORITIES].reverse().map((priority) => {
|
|
9
|
+
const style = priorityStyles[priority];
|
|
10
|
+
return { value: priority, label: priority === 'none' ? 'NONE' : style.label, tone: priorityTone[priority], dim: priority === 'none' };
|
|
11
|
+
});
|
|
12
|
+
export const typeOptions = TASK_TYPES.map((type) => ({ value: type, label: TYPE_LABELS[type] }));
|
|
13
|
+
export function memberOptions(members, meId) {
|
|
14
|
+
return [
|
|
15
|
+
...members.map((member) => ({
|
|
16
|
+
value: member.id,
|
|
17
|
+
label: `@${member.username}`,
|
|
18
|
+
hint: member.id === meId ? `${member.displayName} (you)` : member.displayName,
|
|
19
|
+
})),
|
|
20
|
+
{ value: NONE, label: 'Unassigned', dim: true },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
export function projectOptions(projects) {
|
|
24
|
+
return [
|
|
25
|
+
...projects.map((project) => ({ value: project.id, label: project.name, hint: project.key })),
|
|
26
|
+
{ value: NONE, label: 'No project', dim: true },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
export const fromOption = (value) => (value === NONE ? null : value);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { FileConfigStore } from '../../config/config.js';
|
|
5
|
+
import { CredentialStore } from '../../config/credentials.js';
|
|
6
|
+
import { resolvePaths } from '../../config/paths.js';
|
|
7
|
+
import { ApiClient } from '../../data/remote/api-client.js';
|
|
8
|
+
import { toDisplayError } from '../../utils/errors.js';
|
|
9
|
+
import { useLayout } from '../hooks/use-layout.js';
|
|
10
|
+
import { Layer } from '../input/dispatcher.js';
|
|
11
|
+
import { useKeys } from '../input/KeyProvider.js';
|
|
12
|
+
import { Badge } from '../kit/Badge.js';
|
|
13
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
14
|
+
import { Panel } from '../kit/Panel.js';
|
|
15
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
16
|
+
import { ScreenFrame } from './ScreenFrame.js';
|
|
17
|
+
export function AccessScreen({ active }) {
|
|
18
|
+
const { height, width } = useLayout();
|
|
19
|
+
const [requests, setRequests] = useState([]);
|
|
20
|
+
const [index, setIndex] = useState(0);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const api = useCallback(() => {
|
|
24
|
+
const remote = new FileConfigStore(resolvePaths().configFile).load()?.remote;
|
|
25
|
+
const token = remote ? new CredentialStore(resolvePaths().credentialsFile).token(remote.apiUrl) : null;
|
|
26
|
+
return remote && token ? new ApiClient(remote.apiUrl, token) : null;
|
|
27
|
+
}, []);
|
|
28
|
+
const refresh = useCallback(async () => {
|
|
29
|
+
const client = api();
|
|
30
|
+
if (!client) {
|
|
31
|
+
setError('La revisión de solicitudes requiere una sesión remota.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
setRequests((await client.get('/v1/admin/access-requests')).requests);
|
|
36
|
+
setError('');
|
|
37
|
+
}
|
|
38
|
+
catch (failure) {
|
|
39
|
+
setError(toDisplayError(failure).message);
|
|
40
|
+
}
|
|
41
|
+
}, [api]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!active)
|
|
44
|
+
return;
|
|
45
|
+
const timer = setTimeout(() => void refresh(), 0);
|
|
46
|
+
return () => clearTimeout(timer);
|
|
47
|
+
}, [active, refresh]);
|
|
48
|
+
const decide = async (decision) => {
|
|
49
|
+
const request = requests[index];
|
|
50
|
+
const client = api();
|
|
51
|
+
if (!request || !client || busy)
|
|
52
|
+
return;
|
|
53
|
+
setBusy(true);
|
|
54
|
+
try {
|
|
55
|
+
await client.post(`/v1/admin/access-requests/${request.id}/${decision}`);
|
|
56
|
+
await refresh();
|
|
57
|
+
setIndex(Math.min(index, Math.max(0, requests.length - 2)));
|
|
58
|
+
}
|
|
59
|
+
catch (failure) {
|
|
60
|
+
setError(toDisplayError(failure).message);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
setBusy(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
useKeys(Layer.screen, (input, key) => {
|
|
67
|
+
if (key.upArrow || input === 'k') {
|
|
68
|
+
setIndex((n) => Math.max(0, n - 1));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (key.downArrow || input === 'j') {
|
|
72
|
+
setIndex((n) => Math.min(requests.length - 1, n + 1));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (input === 'y') {
|
|
76
|
+
void decide('approve');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (input === 'n') {
|
|
80
|
+
void decide('reject');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (input === 'r') {
|
|
84
|
+
void refresh();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}, active);
|
|
89
|
+
const request = requests[index];
|
|
90
|
+
return _jsx(ScreenFrame, { title: "Access approvals", aside: `${requests.length} pending`, hints: [[`↑↓`, 'select'], ['y', 'approve'], ['n', 'reject'], ['r', 'refresh'], ['esc', 'back']], children: _jsxs(Box, { flexDirection: "row", gap: 1, height: Math.max(5, height - 3), children: [_jsxs(Panel, { title: "Pending requests", width: Math.max(28, Math.floor(width * 0.42)), children: [requests.length ? requests.map((item, position) => _jsx(Clickable, { active: active, onClick: () => setIndex(position), children: _jsxs(Box, { backgroundColor: position === index ? palette.selection : undefined, gap: 1, children: [_jsx(Text, { color: palette.accent, children: position === index ? symbols.pointer : ' ' }), _jsx(Text, { color: palette.text, children: `@${item.username}` }), _jsx(Badge, { tone: "warning", children: "pending" })] }) }, item.id)) : _jsx(Text, { color: palette.muted, children: error ? 'Could not load requests.' : 'No pending requests.' }), busy ? _jsx(Text, { color: palette.muted, children: "Saving decision\u2026" }) : null] }), _jsxs(Panel, { title: request ? request.displayName : 'Request details', flexGrow: 1, children: [error ? _jsx(Text, { color: palette.danger, children: error }) : null, request ? _jsxs(_Fragment, { children: [_jsx(Text, { color: palette.muted, children: `@${request.username} · GitHub ID ${request.githubId} · ${request.countryCode ?? 'country missing'}` }), _jsx(Text, { color: palette.muted, children: `Date of birth: ${request.dateOfBirth ?? 'not provided'} · ${request.submittedAt}` }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.text, bold: true, children: "Interest letter" }) }), _jsx(Text, { color: palette.text, wrap: "wrap", children: request.letter }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsx(Text, { color: palette.success, children: "y approve" }), _jsx(Text, { color: palette.danger, children: "n reject" })] })] }) : null] })] }) });
|
|
91
|
+
}
|