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,16 @@
|
|
|
1
|
+
import { SojaError } from '../domain/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* A Git (or gh) command failed or is unavailable. `message` is safe to show;
|
|
4
|
+
* `code` lets interfaces offer a follow-up (force delete, abort merge,
|
|
5
|
+
* retry interactively); `suggestions` are next steps in plain words.
|
|
6
|
+
*/
|
|
7
|
+
export class GitError extends SojaError {
|
|
8
|
+
code;
|
|
9
|
+
suggestions;
|
|
10
|
+
constructor(message, options = {}) {
|
|
11
|
+
const suggestions = options.suggestions ?? (options.hint ? [options.hint] : []);
|
|
12
|
+
super(message, { hint: options.hint ?? suggestions[0], cause: options.cause });
|
|
13
|
+
this.code = options.code ?? 'failed';
|
|
14
|
+
this.suggestions = suggestions;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useApp } from 'ink';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { toDisplayError } from '../utils/errors.js';
|
|
5
|
+
import { AppStateProvider } from './app-state.js';
|
|
6
|
+
import { Splash } from './branding/Splash.js';
|
|
7
|
+
import { Layer } from './input/dispatcher.js';
|
|
8
|
+
import { KeyProvider, useKeys } from './input/KeyProvider.js';
|
|
9
|
+
import { SetupScreen } from './screens/SetupScreen.js';
|
|
10
|
+
import { WelcomeScreen } from './screens/WelcomeScreen.js';
|
|
11
|
+
import { TerminalSizeProvider } from './hooks/use-terminal-size.js';
|
|
12
|
+
import { Shell } from './Shell.js';
|
|
13
|
+
import { palette, symbols } from './theme/theme.js';
|
|
14
|
+
function resolvePhase(state) {
|
|
15
|
+
if (state.failure)
|
|
16
|
+
return { kind: 'failed', error: state.failure };
|
|
17
|
+
// A session made by setup goes straight in; setup already showed the splash.
|
|
18
|
+
if (state.created)
|
|
19
|
+
return { kind: 'ready', session: state.created };
|
|
20
|
+
if (state.stored === null)
|
|
21
|
+
return { kind: 'setup' };
|
|
22
|
+
if (state.stored && state.splashDone)
|
|
23
|
+
return { kind: 'ready', session: state.stored };
|
|
24
|
+
return { kind: 'splash' };
|
|
25
|
+
}
|
|
26
|
+
export function App({ services, splashMs = 700, cwd = process.cwd(), initialRoute, updates, mouse = false, welcomeOnLocal = false }) {
|
|
27
|
+
return (_jsx(KeyProvider, { mouse: mouse, children: _jsx(TerminalSizeProvider, { children: _jsx(Boot, { services: services, splashMs: splashMs, cwd: cwd, initialRoute: initialRoute, updates: updates, welcomeOnLocal: welcomeOnLocal }) }) }));
|
|
28
|
+
}
|
|
29
|
+
function Boot({ services, splashMs, cwd, initialRoute, updates, welcomeOnLocal }) {
|
|
30
|
+
const { exit } = useApp();
|
|
31
|
+
// undefined while loading, null when setup is needed.
|
|
32
|
+
const [stored, setStored] = useState(undefined);
|
|
33
|
+
const [created, setCreated] = useState(null);
|
|
34
|
+
const [localSetup, setLocalSetup] = useState(false);
|
|
35
|
+
const [localChoiceDone, setLocalChoiceDone] = useState(false);
|
|
36
|
+
const [failure, setFailure] = useState(null);
|
|
37
|
+
const [splashDone, setSplashDone] = useState(splashMs <= 0);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
services.session.current().then(setStored, (error) => setFailure(toDisplayError(error)));
|
|
40
|
+
}, [services]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (splashDone)
|
|
43
|
+
return;
|
|
44
|
+
const timer = setTimeout(() => setSplashDone(true), splashMs);
|
|
45
|
+
return () => clearTimeout(timer);
|
|
46
|
+
}, [splashDone, splashMs]);
|
|
47
|
+
const phase = resolvePhase({ stored, created, failure, splashDone });
|
|
48
|
+
useKeys(Layer.screen, (input) => {
|
|
49
|
+
if (phase.kind === 'failed' && input === 'q')
|
|
50
|
+
exit();
|
|
51
|
+
else if (phase.kind === 'splash')
|
|
52
|
+
setSplashDone(true);
|
|
53
|
+
else
|
|
54
|
+
return false;
|
|
55
|
+
return true;
|
|
56
|
+
}, phase.kind === 'splash' || phase.kind === 'failed');
|
|
57
|
+
switch (phase.kind) {
|
|
58
|
+
case 'splash':
|
|
59
|
+
return _jsx(Splash, {});
|
|
60
|
+
case 'setup':
|
|
61
|
+
return localSetup ? _jsx(SetupScreen, { services: services, onDone: setCreated }) : _jsx(WelcomeScreen, { onLocal: () => { setLocalSetup(true); setLocalChoiceDone(true); } });
|
|
62
|
+
case 'failed':
|
|
63
|
+
if (services.environment.mode === 'remote')
|
|
64
|
+
return _jsx(WelcomeScreen, { onLocal: () => exit() });
|
|
65
|
+
return (_jsxs(Box, { paddingX: 1, paddingTop: 1, flexDirection: "column", children: [_jsx(Text, { color: palette.danger, children: `${symbols.cross} ${phase.error.message}` }), phase.error.hint ? _jsx(Text, { dimColor: true, children: phase.error.hint }) : null, phase.error.debug ? _jsx(Text, { dimColor: true, children: phase.error.debug }) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "q quit" }) })] }));
|
|
66
|
+
case 'ready':
|
|
67
|
+
if (services.environment.mode === 'local' && welcomeOnLocal && !localChoiceDone)
|
|
68
|
+
return _jsx(WelcomeScreen, { onLocal: () => setLocalChoiceDone(true) });
|
|
69
|
+
return (_jsx(AppStateProvider, { services: services, cwd: cwd, initialSession: phase.session, ...(initialRoute ? { initialRoute } : {}), ...(updates ? { updates } : {}), children: _jsx(Shell, {}) }));
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/ui/Shell.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useAppState } from './app-state.js';
|
|
5
|
+
import { ActiveScope, ChromeProvider, useChromeHints } from './chrome/context.js';
|
|
6
|
+
import { Sidebar } from './chrome/Sidebar.js';
|
|
7
|
+
import { StatusBar } from './chrome/StatusBar.js';
|
|
8
|
+
import { Toast } from './chrome/Toast.js';
|
|
9
|
+
import { TopBar } from './chrome/TopBar.js';
|
|
10
|
+
import { useCommandPalette } from './hooks/use-commands.js';
|
|
11
|
+
import { FOOTER_GAP_ROWS, footerRowsFor, useLayout } from './hooks/use-layout.js';
|
|
12
|
+
import { useQuery } from './hooks/use-query.js';
|
|
13
|
+
import { Layer } from './input/dispatcher.js';
|
|
14
|
+
import { useKeys } from './input/KeyProvider.js';
|
|
15
|
+
import { CommitOverlay } from './overlays/CommitOverlay.js';
|
|
16
|
+
import { ConfirmOverlay } from './overlays/ConfirmOverlay.js';
|
|
17
|
+
import { GitLogOverlay } from './overlays/GitLogOverlay.js';
|
|
18
|
+
import { GitRunOverlay } from './overlays/GitRunOverlay.js';
|
|
19
|
+
import { NewTaskOverlay } from './overlays/NewTaskOverlay.js';
|
|
20
|
+
import { PickerOverlay } from './overlays/PickerOverlay.js';
|
|
21
|
+
import { PromptOverlay } from './overlays/PromptOverlay.js';
|
|
22
|
+
import { SearchOverlay } from './overlays/SearchOverlay.js';
|
|
23
|
+
import { HelpScreen } from './screens/HelpScreen.js';
|
|
24
|
+
import { ProjectsScreen } from './screens/ProjectsScreen.js';
|
|
25
|
+
import { TaskListScreen } from './screens/TaskListScreen.js';
|
|
26
|
+
import { TaskScreen } from './screens/TaskScreen.js';
|
|
27
|
+
import { WorkspacesScreen } from './screens/WorkspacesScreen.js';
|
|
28
|
+
import { ChatScreen } from './screens/ChatScreen.js';
|
|
29
|
+
import { AccessScreen } from './screens/AccessScreen.js';
|
|
30
|
+
import { ApiClient } from '../data/remote/api-client.js';
|
|
31
|
+
import { CredentialStore } from '../config/credentials.js';
|
|
32
|
+
import { resolvePaths } from '../config/paths.js';
|
|
33
|
+
/**
|
|
34
|
+
* The running app, laid out like a desktop application: title bar, sidebar,
|
|
35
|
+
* the screen stack in the main area, status bar, and floating windows and
|
|
36
|
+
* notices on top. Screens below the top stay mounted (hidden) so going back
|
|
37
|
+
* restores the selection you left.
|
|
38
|
+
*/
|
|
39
|
+
export function Shell() {
|
|
40
|
+
return (_jsx(ChromeProvider, { children: _jsx(Frame, {}) }));
|
|
41
|
+
}
|
|
42
|
+
function Frame() {
|
|
43
|
+
const { session, services, stack, route, overlay, openOverlay, go, quit, cwd, detectMerges, followPullRequests, syncStatus } = useAppState();
|
|
44
|
+
const hints = useChromeHints();
|
|
45
|
+
// New tasks default to the open project, or to the project linked to the repository SOJA runs in.
|
|
46
|
+
const here = useQuery(async () => (await services.projects.findByRepository(session, cwd))?.id ?? null, `cwd:${session.workspace.id}`);
|
|
47
|
+
const routeProjectId = route.name === 'project' ? route.projectId : null;
|
|
48
|
+
const newTaskProjectId = routeProjectId ?? here.data ?? null;
|
|
49
|
+
const openPalette = useCommandPalette(newTaskProjectId);
|
|
50
|
+
const projectId = routeProjectId;
|
|
51
|
+
const project = useQuery(async () => (projectId ? (await services.projects.get(session, projectId)).name : null), `header:${projectId ?? ''}`);
|
|
52
|
+
const chat = services.chat;
|
|
53
|
+
const chatTotals = useQuery(async () => (chat ? chat.totals(session) : null), `chat-totals:${session.workspace.id}`);
|
|
54
|
+
const accountBadge = useQuery(async () => {
|
|
55
|
+
if (services.environment.mode !== 'remote')
|
|
56
|
+
return false;
|
|
57
|
+
const token = new CredentialStore(resolvePaths().credentialsFile).token(services.environment.server);
|
|
58
|
+
if (!token)
|
|
59
|
+
return false;
|
|
60
|
+
const { user } = await new ApiClient(services.environment.server, token).get('/v1/profile');
|
|
61
|
+
return user.isCeo === true;
|
|
62
|
+
}, `account-badge:${session.user.id}`);
|
|
63
|
+
// On launch and whenever you come back to a task list, catch merges done elsewhere.
|
|
64
|
+
const onList = route.name === 'home' || route.name === 'project';
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (onList && !overlay) {
|
|
67
|
+
void detectMerges();
|
|
68
|
+
void followPullRequests();
|
|
69
|
+
}
|
|
70
|
+
}, [onList, overlay, stack.length, detectMerges, followPullRequests]);
|
|
71
|
+
// With SOJA open, GitHub is checked every few minutes (merges and CI happen there).
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const timer = setInterval(() => void followPullRequests(), 180_000);
|
|
74
|
+
return () => clearInterval(timer);
|
|
75
|
+
}, [followPullRequests]);
|
|
76
|
+
useKeys(Layer.global, (input, key) => {
|
|
77
|
+
if (input === ':' || (key.ctrl && input === 'k'))
|
|
78
|
+
openPalette();
|
|
79
|
+
else if (key.ctrl || key.meta)
|
|
80
|
+
return false;
|
|
81
|
+
else if (input === '/')
|
|
82
|
+
openOverlay({ kind: 'search' });
|
|
83
|
+
else if (input === 'n')
|
|
84
|
+
openOverlay({ kind: 'new-task', projectId: newTaskProjectId });
|
|
85
|
+
else if (input === '?')
|
|
86
|
+
go({ type: 'push', route: { name: 'help' } });
|
|
87
|
+
else if (input === 'p')
|
|
88
|
+
go({ type: 'push', route: { name: 'projects' } });
|
|
89
|
+
else if (input === 'w')
|
|
90
|
+
go({ type: 'push', route: { name: 'workspaces' } });
|
|
91
|
+
else if (input === 'A')
|
|
92
|
+
go({ type: 'push', route: { name: 'access' } });
|
|
93
|
+
else if (input === '#' && chat)
|
|
94
|
+
go({ type: 'push', route: { name: 'chat' } });
|
|
95
|
+
else if (key.escape && stack.length > 1)
|
|
96
|
+
go({ type: 'pop' });
|
|
97
|
+
else if (input === 'q') {
|
|
98
|
+
if (stack.length > 1)
|
|
99
|
+
go({ type: 'pop' });
|
|
100
|
+
else
|
|
101
|
+
quit();
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
return false;
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
const { columns, rows, sidebar } = useLayout();
|
|
108
|
+
const { flash } = useAppState();
|
|
109
|
+
const server = services.environment.mode === 'remote' ? new URL(services.environment.server).host : undefined;
|
|
110
|
+
const syncLabel = syncLabelFor(syncStatus);
|
|
111
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(TopBar, { columns: columns, workspace: session.workspace.name, trail: trailFor(route, project.data), username: session.user.username, isCeo: accountBadge.data === true, server: server, sync: syncLabel, chat: route.name === 'chat' ? undefined : (chatTotals.data ?? undefined) }), _jsxs(Box, { flexGrow: 1, flexShrink: 1, children: [sidebar ? _jsx(Sidebar, { width: sidebar, height: rows - 1 - FOOTER_GAP_ROWS - footerRowsFor(columns), active: !overlay }) : null, _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: stack.map((entry, index) => {
|
|
112
|
+
const top = index === stack.length - 1;
|
|
113
|
+
// The screen under a window stays visible, but only the window takes keys and clicks.
|
|
114
|
+
const active = top && !overlay;
|
|
115
|
+
return (_jsx(Box, { display: top ? 'flex' : 'none', flexDirection: "column", flexGrow: 1, children: _jsx(ActiveScope, { active: active, children: _jsx(ScreenFor, { route: entry, active: active }) }) }, `${index}:${JSON.stringify(entry)}`));
|
|
116
|
+
}) })] }), _jsx(Box, { height: FOOTER_GAP_ROWS, flexShrink: 0 }), _jsx(StatusBar, { columns: columns, mode: overlay ? modeForOverlay(overlay) : modeFor(route), hints: hints, right: server ? undefined : 'local' }), overlay ? (_jsx(ActiveScope, { active: true, children: _jsx(OverlayFor, { overlay: overlay }) })) : null, flash ? _jsx(Toast, { flash: flash, columns: columns, rows: rows }) : null] }));
|
|
117
|
+
}
|
|
118
|
+
function ScreenFor({ route, active }) {
|
|
119
|
+
switch (route.name) {
|
|
120
|
+
case 'home':
|
|
121
|
+
return _jsx(TaskListScreen, { active: active, ...(route.filter ? { initialFilter: route.filter } : {}) });
|
|
122
|
+
case 'task':
|
|
123
|
+
return _jsx(TaskScreen, { active: active, taskRef: route.ref });
|
|
124
|
+
case 'projects':
|
|
125
|
+
return _jsx(ProjectsScreen, { active: active });
|
|
126
|
+
case 'project':
|
|
127
|
+
return _jsx(TaskListScreen, { active: active, projectId: route.projectId });
|
|
128
|
+
case 'workspaces':
|
|
129
|
+
return _jsx(WorkspacesScreen, { active: active });
|
|
130
|
+
case 'access':
|
|
131
|
+
return _jsx(AccessScreen, { active: active });
|
|
132
|
+
case 'help':
|
|
133
|
+
return _jsx(HelpScreen, {});
|
|
134
|
+
case 'chat':
|
|
135
|
+
return _jsx(ChatScreen, { active: active, ...(route.channel ? { initialChannel: route.channel } : {}) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function OverlayFor({ overlay }) {
|
|
139
|
+
switch (overlay.kind) {
|
|
140
|
+
case 'confirm':
|
|
141
|
+
return _jsx(ConfirmOverlay, { spec: overlay }, overlay.title + (overlay.context ?? ''));
|
|
142
|
+
case 'picker':
|
|
143
|
+
return _jsx(PickerOverlay, { spec: overlay }, overlay.title + (overlay.context ?? ''));
|
|
144
|
+
case 'prompt':
|
|
145
|
+
return _jsx(PromptOverlay, { spec: overlay }, overlay.title + (overlay.context ?? ''));
|
|
146
|
+
case 'search':
|
|
147
|
+
return _jsx(SearchOverlay, { spec: overlay });
|
|
148
|
+
case 'new-task':
|
|
149
|
+
return _jsx(NewTaskOverlay, { spec: overlay });
|
|
150
|
+
case 'git-run':
|
|
151
|
+
return _jsx(GitRunOverlay, { spec: overlay }, `${overlay.title}:${overlay.context ?? ''}`);
|
|
152
|
+
case 'commit':
|
|
153
|
+
return _jsx(CommitOverlay, { spec: overlay });
|
|
154
|
+
case 'git-log':
|
|
155
|
+
return _jsx(GitLogOverlay, { spec: overlay });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function trailFor(route, projectName) {
|
|
159
|
+
switch (route.name) {
|
|
160
|
+
case 'project':
|
|
161
|
+
return projectName ? ['Projects', projectName] : ['Projects'];
|
|
162
|
+
case 'task':
|
|
163
|
+
return [route.ref];
|
|
164
|
+
case 'projects':
|
|
165
|
+
return ['Projects'];
|
|
166
|
+
case 'workspaces':
|
|
167
|
+
return ['Workspaces'];
|
|
168
|
+
case 'access':
|
|
169
|
+
return ['Access approvals'];
|
|
170
|
+
case 'help':
|
|
171
|
+
return ['Help'];
|
|
172
|
+
case 'chat':
|
|
173
|
+
return route.channel ? ['Chat', `#${route.channel.replace(/^#/, '')}`] : ['Chat'];
|
|
174
|
+
case 'home':
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function modeFor(route) {
|
|
179
|
+
switch (route.name) {
|
|
180
|
+
case 'home':
|
|
181
|
+
return 'Tasks';
|
|
182
|
+
case 'project':
|
|
183
|
+
return 'Project';
|
|
184
|
+
default:
|
|
185
|
+
return route.name;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const OVERLAY_MODES = {
|
|
189
|
+
picker: 'Select',
|
|
190
|
+
confirm: 'Confirm',
|
|
191
|
+
prompt: 'Input',
|
|
192
|
+
search: 'Search',
|
|
193
|
+
'new-task': 'New task',
|
|
194
|
+
'git-run': 'Git',
|
|
195
|
+
commit: 'Commit',
|
|
196
|
+
'git-log': 'Git log',
|
|
197
|
+
};
|
|
198
|
+
function modeForOverlay(overlay) {
|
|
199
|
+
return OVERLAY_MODES[overlay.kind];
|
|
200
|
+
}
|
|
201
|
+
function syncLabelFor(status) {
|
|
202
|
+
if (!status)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (status.syncing)
|
|
205
|
+
return { text: 'syncing…', tone: 'busy' };
|
|
206
|
+
if (status.online === false)
|
|
207
|
+
return { text: `offline${status.pending ? ` · ${status.pending} pending` : ''}`, tone: 'warn' };
|
|
208
|
+
if (status.pending)
|
|
209
|
+
return { text: `${status.pending} pending`, tone: status.lastError ? 'warn' : 'busy' };
|
|
210
|
+
return { text: 'synced', tone: 'ok' };
|
|
211
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useApp } from 'ink';
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
4
|
+
import { toDisplayError } from '../utils/errors.js';
|
|
5
|
+
import { navigate } from './navigation/routes.js';
|
|
6
|
+
const AppStateContext = createContext(null);
|
|
7
|
+
export function AppStateProvider({ services, cwd, initialSession, initialRoute, updates, children }) {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const [session, setSession] = useState(initialSession);
|
|
10
|
+
const [stack, dispatch] = useReducer(navigate, navigate([{ name: 'home' }], { type: 'reset', ...(initialRoute ? { route: initialRoute } : {}) }));
|
|
11
|
+
const [overlay, setOverlay] = useState(null);
|
|
12
|
+
const [flash, setFlash] = useState(null);
|
|
13
|
+
const [revision, setRevision] = useState(0);
|
|
14
|
+
const [queryEpochs, setQueryEpochs] = useState({});
|
|
15
|
+
const [homeFilter, setHomeFilter] = useState(initialRoute?.name === 'home' && initialRoute.filter ? initialRoute.filter : 'mine');
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!flash)
|
|
18
|
+
return;
|
|
19
|
+
const timer = setTimeout(() => setFlash(null), flash.tone === 'error' ? 6000 : 3000);
|
|
20
|
+
return () => clearTimeout(timer);
|
|
21
|
+
}, [flash]);
|
|
22
|
+
const notify = useCallback((text, tone = 'info', hint) => {
|
|
23
|
+
setFlash({ id: Date.now(), text, tone, hint });
|
|
24
|
+
}, []);
|
|
25
|
+
const queryVersion = useCallback((topics) => topics.map((topic) => `${topic}:${queryEpochs[topic] ?? 0}`).join('|'), [queryEpochs]);
|
|
26
|
+
const invalidateQueries = useCallback((topics) => {
|
|
27
|
+
if (topics.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
setQueryEpochs((current) => {
|
|
30
|
+
const next = { ...current };
|
|
31
|
+
for (const topic of new Set(topics))
|
|
32
|
+
next[topic] = (next[topic] ?? 0) + 1;
|
|
33
|
+
return next;
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
const [syncStatus, setSyncStatus] = useState(null);
|
|
37
|
+
// Once per launch, and at most one GitHub request a day.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!updates)
|
|
40
|
+
return;
|
|
41
|
+
let active = true;
|
|
42
|
+
void updates().then((update) => {
|
|
43
|
+
if (active && update?.available)
|
|
44
|
+
notify(`SOJA v${update.latest} is available`, 'info', 'Run `soja update` to install it.');
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
active = false;
|
|
48
|
+
};
|
|
49
|
+
}, [updates, notify]);
|
|
50
|
+
const run = useCallback(async (action, success) => {
|
|
51
|
+
try {
|
|
52
|
+
await action();
|
|
53
|
+
setRevision((value) => value + 1);
|
|
54
|
+
if (services.sync)
|
|
55
|
+
void services.sync.status(session.workspace.id).then(setSyncStatus);
|
|
56
|
+
if (success)
|
|
57
|
+
notify(success, 'success');
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const display = toDisplayError(error);
|
|
62
|
+
notify(display.message, 'error', display.hint);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}, [notify, services, session.workspace.id]);
|
|
66
|
+
const lastScan = useRef(0);
|
|
67
|
+
const detectMerges = useCallback(async (only) => {
|
|
68
|
+
if (!only) {
|
|
69
|
+
if (Date.now() - lastScan.current < 10_000)
|
|
70
|
+
return;
|
|
71
|
+
lastScan.current = Date.now();
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const merged = (await services.git.detectMerges(session, cwd, only ? { only } : {})).filter((result) => result.kind === 'merged');
|
|
75
|
+
const [first] = merged;
|
|
76
|
+
if (!first)
|
|
77
|
+
return;
|
|
78
|
+
notify(merged.length === 1
|
|
79
|
+
? `${first.task.ref} was merged into ${first.into} outside SOJA → Done`
|
|
80
|
+
: `${merged.length} tasks were merged outside SOJA → Done`, 'success');
|
|
81
|
+
setRevision((value) => value + 1);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Detection is best effort; the task views report Git problems themselves.
|
|
85
|
+
}
|
|
86
|
+
}, [services, session, cwd, notify]);
|
|
87
|
+
const lastPrScan = useRef(0);
|
|
88
|
+
const followPullRequests = useCallback(async (only) => {
|
|
89
|
+
if (!only) {
|
|
90
|
+
if (Date.now() - lastPrScan.current < 120_000)
|
|
91
|
+
return;
|
|
92
|
+
lastPrScan.current = Date.now();
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const updates = await services.git.followPullRequests(session, cwd, only ? { only } : {});
|
|
96
|
+
const merged = updates.filter((update) => update.kind === 'merged');
|
|
97
|
+
const failed = updates.filter((update) => update.kind === 'checks_failed');
|
|
98
|
+
const [firstMerged] = merged;
|
|
99
|
+
const [firstFailed] = failed;
|
|
100
|
+
if (firstMerged) {
|
|
101
|
+
notify(merged.length === 1
|
|
102
|
+
? `${firstMerged.task.ref}: PR #${firstMerged.pr.number} was merged on GitHub → Done`
|
|
103
|
+
: `${merged.length} pull requests were merged on GitHub → Done`, 'success');
|
|
104
|
+
}
|
|
105
|
+
else if (firstFailed) {
|
|
106
|
+
notify(failed.length === 1
|
|
107
|
+
? `${firstFailed.task.ref}: checks failed on PR #${firstFailed.pr.number} (${firstFailed.pr.checks.failing.join(', ')})`
|
|
108
|
+
: `Checks failed on ${failed.length} pull requests`, 'error');
|
|
109
|
+
}
|
|
110
|
+
if (updates.length)
|
|
111
|
+
setRevision((value) => value + 1);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Best effort: without gh, or offline, there is simply nothing new.
|
|
115
|
+
}
|
|
116
|
+
}, [services, session, cwd, notify]);
|
|
117
|
+
// Remote mode: sync on launch, every 30 s and shortly after changes; refresh views when it finishes.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const sync = services.sync;
|
|
120
|
+
if (!sync)
|
|
121
|
+
return;
|
|
122
|
+
const workspaceId = session.workspace.id;
|
|
123
|
+
let active = true;
|
|
124
|
+
const updateStatus = () => {
|
|
125
|
+
void sync.status(workspaceId).then((status) => {
|
|
126
|
+
if (active)
|
|
127
|
+
setSyncStatus(status);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
// A sync only invalidates queries for entities that actually changed in the replica.
|
|
131
|
+
const onSync = (report) => {
|
|
132
|
+
updateStatus();
|
|
133
|
+
if (!report || !active)
|
|
134
|
+
return;
|
|
135
|
+
const topics = [];
|
|
136
|
+
if (report.changes.taskRefs.length) {
|
|
137
|
+
topics.push(`tasks:${workspaceId}`, `projects:${workspaceId}`, `sidebar-tasks:${workspaceId}`);
|
|
138
|
+
for (const ref of report.changes.taskRefs)
|
|
139
|
+
topics.push(`task:${workspaceId}:${ref}`);
|
|
140
|
+
}
|
|
141
|
+
if (report.changes.projects)
|
|
142
|
+
topics.push(`projects:${workspaceId}`);
|
|
143
|
+
if (report.changes.members)
|
|
144
|
+
topics.push(`members:${workspaceId}`);
|
|
145
|
+
if (report.changes.chatChannelIds.length || report.changes.chatReads) {
|
|
146
|
+
topics.push(`chat-channels:${workspaceId}`, `sidebar-channels:${workspaceId}`, `chat-totals:${workspaceId}`);
|
|
147
|
+
}
|
|
148
|
+
for (const channelId of report.changes.chatMessageChannelIds) {
|
|
149
|
+
topics.push(`chat:${workspaceId}`, `chat-messages:${channelId}`, `chat-channels:${workspaceId}`, `sidebar-channels:${workspaceId}`, `chat-totals:${workspaceId}`, `chat-notices:${workspaceId}`);
|
|
150
|
+
}
|
|
151
|
+
invalidateQueries(topics);
|
|
152
|
+
if (report.chatRejected)
|
|
153
|
+
notify(`Chat: ${report.chatRejected} message change${report.chatRejected === 1 ? '' : 's'} not applied`, 'error', 'Open the chat (#) to get your text back.');
|
|
154
|
+
else if (report.conflicts || report.rejected) {
|
|
155
|
+
const parts = [
|
|
156
|
+
report.conflicts && `${report.conflicts} conflict${report.conflicts === 1 ? '' : 's'}`,
|
|
157
|
+
report.rejected && `${report.rejected} rejected change${report.rejected === 1 ? '' : 's'}`,
|
|
158
|
+
];
|
|
159
|
+
notify(`Sync: ${parts.filter(Boolean).join(', ')}`, 'error', 'Open the task and press ! to review.');
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const runSync = () => sync.syncNow(workspaceId);
|
|
163
|
+
const unsubscribe = sync.subscribe(onSync);
|
|
164
|
+
updateStatus();
|
|
165
|
+
// Real time (chat and "something changed"); each connection starts with a sync.
|
|
166
|
+
const stopLive = sync.startLive(workspaceId);
|
|
167
|
+
void runSync();
|
|
168
|
+
const interval = setInterval(() => void runSync(), 30_000);
|
|
169
|
+
return () => {
|
|
170
|
+
active = false;
|
|
171
|
+
unsubscribe();
|
|
172
|
+
stopLive();
|
|
173
|
+
clearInterval(interval);
|
|
174
|
+
};
|
|
175
|
+
}, [services, session.workspace.id, notify, invalidateQueries]);
|
|
176
|
+
const changeSession = useCallback((next) => {
|
|
177
|
+
setSession(next);
|
|
178
|
+
dispatch({ type: 'reset' });
|
|
179
|
+
setRevision((value) => value + 1);
|
|
180
|
+
}, []);
|
|
181
|
+
const value = useMemo(() => ({
|
|
182
|
+
services,
|
|
183
|
+
cwd,
|
|
184
|
+
session,
|
|
185
|
+
setSession: changeSession,
|
|
186
|
+
stack,
|
|
187
|
+
route: stack.at(-1) ?? { name: 'home' },
|
|
188
|
+
go: dispatch,
|
|
189
|
+
overlay,
|
|
190
|
+
openOverlay: setOverlay,
|
|
191
|
+
closeOverlay: (which) => setOverlay((current) => (!which || current === which ? null : current)),
|
|
192
|
+
flash,
|
|
193
|
+
notify,
|
|
194
|
+
revision,
|
|
195
|
+
queryVersion,
|
|
196
|
+
invalidateQueries,
|
|
197
|
+
run,
|
|
198
|
+
refresh: () => setRevision((value) => value + 1),
|
|
199
|
+
detectMerges,
|
|
200
|
+
followPullRequests,
|
|
201
|
+
syncStatus,
|
|
202
|
+
homeFilter,
|
|
203
|
+
setHomeFilter,
|
|
204
|
+
quit: exit,
|
|
205
|
+
}), [services, cwd, session, changeSession, stack, overlay, flash, notify, revision, queryVersion, invalidateQueries, run, detectMerges, followPullRequests, syncStatus, homeFilter, exit]);
|
|
206
|
+
return _jsx(AppStateContext.Provider, { value: value, children: children });
|
|
207
|
+
}
|
|
208
|
+
export function useAppState() {
|
|
209
|
+
const state = useContext(AppStateContext);
|
|
210
|
+
if (!state)
|
|
211
|
+
throw new Error('useAppState must be used inside <AppStateProvider>');
|
|
212
|
+
return state;
|
|
213
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
4
|
+
import { APP_NAME } from './brand.js';
|
|
5
|
+
/** One-line mark: the name and the cursor that ends the full wordmark. */
|
|
6
|
+
export function CompactLogo() {
|
|
7
|
+
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: APP_NAME }), _jsx(Text, { color: palette.accent, children: symbols.cursor })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { brandGradient, palette } from '../theme/theme.js';
|
|
4
|
+
import { WORDMARK, WORDMARK_CURSOR } from './brand.js';
|
|
5
|
+
/** Colors a line column by column so the gradient runs left to right across the mark. */
|
|
6
|
+
function gradientSegments(line) {
|
|
7
|
+
const width = WORDMARK[0]?.length ?? line.length;
|
|
8
|
+
const segments = [];
|
|
9
|
+
for (const [index, char] of [...line].entries()) {
|
|
10
|
+
const color = brandGradient[Math.min(brandGradient.length - 1, Math.floor((index / width) * brandGradient.length))];
|
|
11
|
+
const last = segments.at(-1);
|
|
12
|
+
if (last && last.color === color)
|
|
13
|
+
last.text += char;
|
|
14
|
+
else
|
|
15
|
+
segments.push({ text: char, color: color ?? palette.accent });
|
|
16
|
+
}
|
|
17
|
+
return segments;
|
|
18
|
+
}
|
|
19
|
+
/** The full SOJA wordmark with its trailing terminal cursor. */
|
|
20
|
+
export function Logo({ cursorVisible = true }) {
|
|
21
|
+
return (_jsx(Box, { flexDirection: "column", children: WORDMARK.map((line, row) => (_jsxs(Text, { children: [gradientSegments(line).map((segment, index) => (_jsx(Text, { color: segment.color, children: segment.text }, index))), row === WORDMARK.length - 1 ? (_jsx(Text, { color: palette.accent, children: cursorVisible ? ` ${WORDMARK_CURSOR}` : '' })) : null] }, row))) }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useAnimation } from 'ink';
|
|
3
|
+
import { useTerminalSize } from '../hooks/use-terminal-size.js';
|
|
4
|
+
import { Spinner } from '../kit/Spinner.js';
|
|
5
|
+
import { palette } from '../theme/theme.js';
|
|
6
|
+
import { APP_AUTHOR, APP_DESCRIPTION, APP_SLOGAN, APP_VERSION, WORDMARK_WIDTH } from './brand.js';
|
|
7
|
+
import { CompactLogo } from './CompactLogo.js';
|
|
8
|
+
import { Logo } from './Logo.js';
|
|
9
|
+
/**
|
|
10
|
+
* SOJA's identity. Full screen and centered at launch; `compact` is the
|
|
11
|
+
* block the setup wizard puts above its card.
|
|
12
|
+
*/
|
|
13
|
+
export function Splash({ status, compact = false }) {
|
|
14
|
+
const { columns, rows } = useTerminalSize();
|
|
15
|
+
const { frame } = useAnimation({ interval: 530 });
|
|
16
|
+
const roomy = columns >= WORDMARK_WIDTH + 4;
|
|
17
|
+
const identity = (_jsxs(Box, { flexDirection: "column", alignItems: "center", paddingBottom: 1, children: [roomy ? _jsx(Logo, { cursorVisible: frame % 2 === 0 }) : _jsx(CompactLogo, {}), _jsxs(Box, { marginTop: 1, flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: palette.text, bold: true, children: APP_DESCRIPTION }), _jsx(Text, { color: palette.muted, children: APP_SLOGAN })] })] }));
|
|
18
|
+
if (compact)
|
|
19
|
+
return identity;
|
|
20
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [identity, _jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: status ?? 'Opening your workspace…' }) }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsx(Text, { color: palette.faint, children: APP_AUTHOR }), _jsx(Text, { color: palette.faint, children: `v${APP_VERSION}` })] })] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of SOJA's identity. Plain strings only (no Ink) so the
|
|
3
|
+
* non-interactive CLI can use them too.
|
|
4
|
+
*/
|
|
5
|
+
export { APP_VERSION } from '../../config/version.js';
|
|
6
|
+
export const APP_NAME = 'SOJA';
|
|
7
|
+
export const APP_DESCRIPTION = 'Software Operations & Job Assistant';
|
|
8
|
+
export const APP_SLOGAN = 'No dashboards. No browser. No bullshit. Just work.';
|
|
9
|
+
export const APP_AUTHOR = 'Enmauel.biz';
|
|
10
|
+
/** Where releases (and the installable package) are published. */
|
|
11
|
+
export const APP_REPOSITORY = 'Mike28108/soja-cli';
|
|
12
|
+
/**
|
|
13
|
+
* The wordmark: a rounded pixel font drawn with half blocks, so every
|
|
14
|
+
* terminal row carries two pixel rows and the mark stays three lines tall.
|
|
15
|
+
*/
|
|
16
|
+
export const WORDMARK = [
|
|
17
|
+
'▄█▀▀▀▀▀▀ ▄█▀▀▀▀█▄ ██ ▄█▀▀▀▀█▄',
|
|
18
|
+
' ▀▀▀▀▀█▄ ██ ██ ██ ██▄▄▄▄██',
|
|
19
|
+
'▄▄▄▄▄▄█▀ ▀█▄▄▄▄█▀ ▀█▄▄▄▄█▀ ██ ██',
|
|
20
|
+
];
|
|
21
|
+
/** The terminal cursor that follows the wordmark. It is SOJA's signature. */
|
|
22
|
+
export const WORDMARK_CURSOR = '▄▄▄';
|
|
23
|
+
export const WORDMARK_WIDTH = Math.max(...WORDMARK.map((line) => line.length)) + 1 + WORDMARK_CURSOR.length;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { FILTER_LABELS } from '../../application/filters.js';
|
|
4
|
+
import { isClosed } from '../../domain/task.js';
|
|
5
|
+
import { useAppState } from '../app-state.js';
|
|
6
|
+
import { useQuery } from '../hooks/use-query.js';
|
|
7
|
+
import { Clickable } from '../kit/Clickable.js';
|
|
8
|
+
import { palette, symbols } from '../theme/theme.js';
|
|
9
|
+
const VIEWS = ['mine', 'all', 'todo', 'in_progress', 'review', 'blocked', 'done'];
|
|
10
|
+
const VIEW_LABELS = { ...FILTER_LABELS, mine: 'My work', all: 'All open' };
|
|
11
|
+
/**
|
|
12
|
+
* Where everything is, always visible on wide terminals: the task views
|
|
13
|
+
* with their counts, the chat channels, the projects. Everything is clickable;
|
|
14
|
+
* the keyboard shortcuts stay the same.
|
|
15
|
+
*/
|
|
16
|
+
export function Sidebar({ width, height, active }) {
|
|
17
|
+
const { services, session, route, go, homeFilter, setHomeFilter } = useAppState();
|
|
18
|
+
const counts = useQuery(async () => {
|
|
19
|
+
const [open, done] = await Promise.all([services.tasks.list(session, 'all'), services.tasks.list(session, 'done')]);
|
|
20
|
+
const byView = { mine: 0, all: open.length, todo: 0, in_progress: 0, review: 0, blocked: 0, done: done.length, archived: 0 };
|
|
21
|
+
for (const task of open) {
|
|
22
|
+
if (task.assigneeId === session.user.id && !isClosed(task.status))
|
|
23
|
+
byView.mine += 1;
|
|
24
|
+
if (task.status === 'todo' || task.status === 'in_progress' || task.status === 'review' || task.status === 'blocked')
|
|
25
|
+
byView[task.status] += 1;
|
|
26
|
+
}
|
|
27
|
+
return byView;
|
|
28
|
+
}, `sidebar-counts:${session.workspace.id}`, [`sidebar-tasks:${session.workspace.id}`]);
|
|
29
|
+
const projects = useQuery(() => services.projects.list(session), `sidebar-projects:${session.workspace.id}`, [`projects:${session.workspace.id}`, `tasks:${session.workspace.id}`]);
|
|
30
|
+
const chat = services.chat;
|
|
31
|
+
const channels = useQuery(async () => (chat ? chat.channels(session) : []), `sidebar-channels:${session.workspace.id}`, [`sidebar-channels:${session.workspace.id}`]);
|
|
32
|
+
const onHome = route.name === 'home';
|
|
33
|
+
const openView = (filter) => {
|
|
34
|
+
setHomeFilter(filter);
|
|
35
|
+
go({ type: 'reset' });
|
|
36
|
+
};
|
|
37
|
+
const open = (next) => go({ type: 'reset', route: next });
|
|
38
|
+
const openChannels = (channels.data ?? []).filter((channel) => !channel.archivedAt);
|
|
39
|
+
// Leave room for the sections that follow; long lists are cut with a "more" line.
|
|
40
|
+
const projectRows = Math.max(1, height - VIEWS.length - 8 - (chat ? Math.min(openChannels.length, 5) + 2 : 0));
|
|
41
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, flexShrink: 0, paddingX: 1, overflow: "hidden", children: [_jsx(Section, { title: "TASKS" }), VIEWS.map((view) => (_jsx(Item, { label: VIEW_LABELS[view], count: counts.data?.[view], selected: onHome && homeFilter === view, onClick: () => openView(view), active: active }, view))), chat ? (_jsxs(_Fragment, { children: [_jsx(Section, { title: "CHAT" }), openChannels.slice(0, 5).map((channel) => (_jsx(Item, { label: `#${channel.name}`, count: channel.unread || undefined, highlight: channel.mentions > 0 ? 'mention' : channel.unread > 0 ? 'unread' : undefined, selected: route.name === 'chat' && route.channel === channel.name, onClick: () => open({ name: 'chat', channel: channel.name }), active: active }, channel.id)))] })) : null, _jsx(Section, { title: "PROJECTS" }), (projects.data ?? []).slice(0, projectRows).map((project) => (_jsx(Item, { label: project.name, count: project.active || undefined, selected: route.name === 'project' && route.projectId === project.id, onClick: () => open({ name: 'project', projectId: project.id }), active: active }, project.id))), (projects.data?.length ?? 0) > projectRows ? (_jsx(Item, { label: `${symbols.ellipsis} all projects`, selected: route.name === 'projects', onClick: () => open({ name: 'projects' }), active: active, muted: true })) : null, (projects.data?.length ?? 0) === 0 && !projects.loading ? _jsx(Text, { color: palette.faint, children: ' none yet · p' }) : null] }));
|
|
42
|
+
}
|
|
43
|
+
function Section({ title }) {
|
|
44
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.faint, bold: true, children: title }) }));
|
|
45
|
+
}
|
|
46
|
+
function Item({ label, count, selected, onClick, active, highlight, muted = false, }) {
|
|
47
|
+
const background = selected ? { backgroundColor: palette.selection } : {};
|
|
48
|
+
const countColor = highlight === 'mention' ? palette.warning : highlight === 'unread' ? palette.accent : palette.faint;
|
|
49
|
+
return (_jsx(Clickable, { onClick: onClick, active: active, children: _jsxs(Box, { justifyContent: "space-between", flexGrow: 1, ...background, children: [_jsxs(Text, { wrap: "truncate-end", ...background, children: [_jsx(Text, { color: palette.accent, children: selected ? symbols.pointer : ' ' }), _jsx(Text, { color: selected ? palette.text : muted ? palette.muted : palette.barText, bold: selected, children: label })] }), count !== undefined ? (_jsx(Text, { ...background, color: countColor, bold: Boolean(highlight), children: `${count} ` })) : null] }) }));
|
|
50
|
+
}
|