aidevops 3.21.12 → 3.22.1
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/README.md +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +3 -2
- package/bun.lock +573 -0
- package/package.json +12 -1
- package/packages/gui-api/package.json +9 -0
- package/packages/gui-api/src/app.ts +49 -0
- package/packages/gui-api/src/file-adapter.ts +207 -0
- package/packages/gui-api/src/server.ts +12 -0
- package/packages/gui-api/src/status-adapter-utils.ts +108 -0
- package/packages/gui-api/src/status-adapter.ts +484 -0
- package/packages/gui-api/src/status-local-repos.ts +239 -0
- package/packages/gui-desktop/package.json +6 -0
- package/packages/gui-desktop/scripts/install-macos-app.sh +1039 -0
- package/packages/gui-shared/package.json +9 -0
- package/packages/gui-shared/src/contracts.ts +341 -0
- package/packages/gui-shared/src/fixtures.ts +250 -0
- package/packages/gui-shared/src/index.ts +2 -0
- package/packages/gui-web/index.html +24 -0
- package/packages/gui-web/package.json +25 -0
- package/packages/gui-web/src/App.tsx +269 -0
- package/packages/gui-web/src/AppNavigation.tsx +399 -0
- package/packages/gui-web/src/AppWorkspace.tsx +114 -0
- package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
- package/packages/gui-web/src/PathActions.tsx +17 -0
- package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
- package/packages/gui-web/src/app-model.ts +546 -0
- package/packages/gui-web/src/dashboard.ts +79 -0
- package/packages/gui-web/src/main.tsx +24 -0
- package/packages/gui-web/src/status-client.ts +93 -0
- package/packages/gui-web/src/styles.css +1540 -0
- package/packages/gui-web/vite.config.ts +14 -0
- package/setup.sh +78 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/* jshint esversion: 11 */
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { GuiFileRootId, GuiStatusData } from "../../gui-shared/src";
|
|
4
|
+
import { SurfaceGlyph } from "./AppNavigation";
|
|
5
|
+
import type { SurfaceId, SurfaceNavItem } from "./app-model";
|
|
6
|
+
import { inventorySurfaceConfigs, text } from "./app-model";
|
|
7
|
+
import { FileExplorerSurface } from "./FileExplorerSurface";
|
|
8
|
+
import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
|
|
9
|
+
import { AiProvidersSurface, LocalReposSurface, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface } from "./StatusSurfaces";
|
|
10
|
+
|
|
11
|
+
export function Workspace({ activeItem, activeSectionLabel, activeSurface, fileRoot, status }: {
|
|
12
|
+
activeItem: SurfaceNavItem;
|
|
13
|
+
activeSectionLabel: string;
|
|
14
|
+
activeSurface: SurfaceId;
|
|
15
|
+
fileRoot: GuiFileRootId | undefined;
|
|
16
|
+
status: GuiStatusData;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<section className="app-inset" aria-label={text.workspaceLabel}>
|
|
20
|
+
<WorkspaceHeader activeItem={activeItem} activeSectionLabel={activeSectionLabel} version={displayVersion(status)} />
|
|
21
|
+
<div className="workspace-scroll">
|
|
22
|
+
<SurfaceContent activeSurface={activeSurface} fileRoot={fileRoot} status={status} />
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function WorkspaceHeader({ activeItem, activeSectionLabel, version }: {
|
|
29
|
+
activeItem: SurfaceNavItem;
|
|
30
|
+
activeSectionLabel: string;
|
|
31
|
+
version: string;
|
|
32
|
+
}) {
|
|
33
|
+
const versionLabel = version.startsWith("v") ? version : `v${version}`;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<header className="workspace-header">
|
|
37
|
+
<div className="header-title">
|
|
38
|
+
<span className="workspace-surface-icon" aria-hidden="true"><SurfaceGlyph icon={activeItem.icon} /></span>
|
|
39
|
+
<div>
|
|
40
|
+
<p>{activeSectionLabel}</p>
|
|
41
|
+
<h1>{activeItem.label}</h1>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="header-actions">
|
|
45
|
+
<label className="workspace-search">
|
|
46
|
+
<span>⌘K</span>
|
|
47
|
+
<input disabled placeholder={text.searchPlaceholder} />
|
|
48
|
+
</label>
|
|
49
|
+
<span className="version-pill" title={`aidevops version ${versionLabel}`}>{versionLabel}</span>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function displayVersion(status: GuiStatusData): string {
|
|
56
|
+
if (status.update.installed_version !== "unknown") {
|
|
57
|
+
return status.update.installed_version;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return status.aidevops_version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function SurfaceContent({ activeSurface, fileRoot, status }: {
|
|
64
|
+
activeSurface: SurfaceId;
|
|
65
|
+
fileRoot: GuiFileRootId | undefined;
|
|
66
|
+
status: GuiStatusData;
|
|
67
|
+
}) {
|
|
68
|
+
const inventoryConfig = inventorySurfaceConfigs[activeSurface];
|
|
69
|
+
const staticSurfaces: Partial<Record<SurfaceId, ReactNode>> = {
|
|
70
|
+
overview: <OverviewSurface status={status} />,
|
|
71
|
+
routines: <PlannedSurface label={text.routines} detail={text.routineDetail} />,
|
|
72
|
+
devices: <PlannedSurface label={text.devices} detail={text.devicesIntro} />,
|
|
73
|
+
vpnsProxies: <PlannedSurface label={text.vpnsProxies} detail={text.vpnsProxiesIntro} />,
|
|
74
|
+
emailAccounts: <PlannedSurface label={text.emailAccounts} detail={text.managementIntro} />,
|
|
75
|
+
messagingAccounts: <PlannedSurface label={text.messagingAccounts} detail={text.managementIntro} />,
|
|
76
|
+
tasks: <PlannedSurface label={text.tasks} detail={text.managementIntro} />,
|
|
77
|
+
contacts: <PlannedSurface label={text.contacts} detail={text.managementIntro} />,
|
|
78
|
+
events: <PlannedSurface label={text.events} detail={text.managementIntro} />,
|
|
79
|
+
notes: <PlannedSurface label={text.notes} detail={text.managementIntro} />,
|
|
80
|
+
bookmarks: <PlannedSurface label={text.bookmarks} detail={text.managementIntro} />,
|
|
81
|
+
websites: <PlannedSurface label={text.websites} detail={text.websitesIntro} />,
|
|
82
|
+
forums: <PlannedSurface label={text.forums} detail={text.forumsIntro} />,
|
|
83
|
+
socialMedia: <PlannedSurface label={text.socialMedia} detail={text.socialMediaIntro} />,
|
|
84
|
+
marketplaces: <PlannedSurface label={text.marketplaces} detail={text.marketplacesIntro} />,
|
|
85
|
+
inbox: <PlannedSurface label={text.inbox} detail={text.projectWorkIntro} />,
|
|
86
|
+
campaigns: <PlannedSurface label={text.campaigns} detail={text.projectWorkIntro} />,
|
|
87
|
+
cases: <PlannedSurface label={text.cases} detail={text.projectWorkIntro} />,
|
|
88
|
+
projectConfig: <PlannedSurface label={text.config} detail={text.projectWorkIntro} />,
|
|
89
|
+
feedback: <PlannedSurface label={text.feedback} detail={text.projectWorkIntro} />,
|
|
90
|
+
knowledge: <PlannedSurface label={text.knowledge} detail={text.projectWorkIntro} />,
|
|
91
|
+
maintenance: <PlannedSurface label={text.maintenance} detail={text.projectWorkIntro} />,
|
|
92
|
+
performance: <PlannedSurface label={text.performance} detail={text.projectWorkIntro} />,
|
|
93
|
+
reports: <PlannedSurface label={text.reports} detail={text.projectWorkIntro} />,
|
|
94
|
+
apps: <AppsSurface />,
|
|
95
|
+
installation: <InstallationSurface />,
|
|
96
|
+
projects: <ProjectsSurface status={status} />,
|
|
97
|
+
security: <SecuritySurface status={status} />,
|
|
98
|
+
aiProviders: <AiProvidersSurface status={status} />,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (activeSurface === "git") {
|
|
102
|
+
return <LocalReposSurface status={status} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (fileRoot) {
|
|
106
|
+
return <FileExplorerSurface key={fileRoot} rootId={fileRoot} />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (inventoryConfig) {
|
|
110
|
+
return <EditableInventorySurface {...inventoryConfig} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return staticSurfaces[activeSurface] ?? null;
|
|
114
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { GuiFileEntry, GuiFileExplorerData, GuiFilePreview, GuiFileRootId, GuiResponseEnvelope } from "@aidevops/gui-shared";
|
|
2
|
+
import { type ReactElement, useEffect, useState } from "react";
|
|
3
|
+
import { text } from "./app-model";
|
|
4
|
+
import { PathActions } from "./PathActions";
|
|
5
|
+
import { fetchFileExplorer, mockedFileExplorer } from "./status-client";
|
|
6
|
+
|
|
7
|
+
export function FileExplorerSurface({ rootId }: { rootId: GuiFileRootId }): ReactElement {
|
|
8
|
+
const [relativePath, setRelativePath] = useState("");
|
|
9
|
+
const [explorer, setExplorer] = useState<GuiResponseEnvelope<GuiFileExplorerData>>(() => mockedFileExplorer(rootId));
|
|
10
|
+
const [markdownFormatted, setMarkdownFormatted] = useState(true);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
fetchFileExplorer(rootId, relativePath)
|
|
15
|
+
.then((response) => {
|
|
16
|
+
if (!cancelled) {
|
|
17
|
+
setExplorer(response);
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
.catch(() => {
|
|
21
|
+
if (!cancelled) {
|
|
22
|
+
setExplorer(mockedFileExplorer(rootId));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
cancelled = true;
|
|
28
|
+
};
|
|
29
|
+
}, [rootId, relativePath]);
|
|
30
|
+
|
|
31
|
+
const data = explorer.data;
|
|
32
|
+
const intro = explorerIntro(rootId, data.root.description);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<section className="surface-page" aria-label={data.root.label}>
|
|
36
|
+
<section className="panel explorer-panel">
|
|
37
|
+
<div className="section-heading split-heading">
|
|
38
|
+
<div>
|
|
39
|
+
<p className="eyebrow">{data.root.path_ref}</p>
|
|
40
|
+
<h2>{data.root.label}</h2>
|
|
41
|
+
<p>{intro}</p>
|
|
42
|
+
</div>
|
|
43
|
+
<PathActions pathRef={data.current_path_ref} />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="file-workspace">
|
|
46
|
+
<div className="file-list-pane">
|
|
47
|
+
<ul className="file-list" aria-label={`${data.root.label} file list`}>
|
|
48
|
+
<li>
|
|
49
|
+
<button className="file-entry parent-entry" disabled={data.current_relative_path.length === 0} onClick={() => setRelativePath(parentPath(data.current_relative_path))} type="button">
|
|
50
|
+
<span>↰</span>
|
|
51
|
+
<strong>{text.parentDirectory}</strong>
|
|
52
|
+
</button>
|
|
53
|
+
</li>
|
|
54
|
+
{data.entries.map((entry) => (
|
|
55
|
+
<FileEntryButton entry={entry} key={entry.path_ref} setRelativePath={setRelativePath} />
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
</div>
|
|
59
|
+
<FilePreviewPanel markdownFormatted={markdownFormatted} preview={data.selected_preview} setMarkdownFormatted={setMarkdownFormatted} />
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
</section>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function FileEntryButton({ entry, setRelativePath }: { entry: GuiFileEntry; setRelativePath: (path: string) => void }): ReactElement {
|
|
67
|
+
return (
|
|
68
|
+
<li className="file-entry-row">
|
|
69
|
+
<button className="file-entry" onClick={() => setRelativePath(entry.relative_path)} type="button">
|
|
70
|
+
<span>{entry.kind === "directory" ? "▸" : "•"}</span>
|
|
71
|
+
<strong>{entry.name}</strong>
|
|
72
|
+
<small>{entry.kind}</small>
|
|
73
|
+
</button>
|
|
74
|
+
<PathActions pathRef={entry.path_ref} />
|
|
75
|
+
</li>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function FilePreviewPanel({ markdownFormatted, preview, setMarkdownFormatted }: {
|
|
80
|
+
markdownFormatted: boolean;
|
|
81
|
+
preview: GuiFilePreview | null;
|
|
82
|
+
setMarkdownFormatted: (value: boolean) => void;
|
|
83
|
+
}): ReactElement {
|
|
84
|
+
if (preview === null) {
|
|
85
|
+
return <aside className="file-preview empty-preview"><p>{text.noPreview}</p></aside>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (preview.mode === "blocked") {
|
|
89
|
+
return <aside className="file-preview empty-preview"><p>{preview.reason}</p></aside>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const isMarkdown = preview.mode === "markdown";
|
|
93
|
+
return (
|
|
94
|
+
<aside className="file-preview">
|
|
95
|
+
<div className="preview-header">
|
|
96
|
+
<div>
|
|
97
|
+
<p className="eyebrow">{preview.language || text.codeView}</p>
|
|
98
|
+
<strong>{preview.path_ref}</strong>
|
|
99
|
+
</div>
|
|
100
|
+
{isMarkdown ? (
|
|
101
|
+
<label className="toggle-row">
|
|
102
|
+
<input checked={markdownFormatted} onChange={(event) => setMarkdownFormatted(event.currentTarget.checked)} type="checkbox" />
|
|
103
|
+
<span>{text.markdownFormatted}</span>
|
|
104
|
+
</label>
|
|
105
|
+
) : null}
|
|
106
|
+
</div>
|
|
107
|
+
{preview.truncated ? <p className="notice compact-notice">{text.truncated}</p> : null}
|
|
108
|
+
{isMarkdown && markdownFormatted ? <MarkdownPreview content={preview.content} /> : <pre className="code-preview"><code>{preview.content}</code></pre>}
|
|
109
|
+
</aside>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function MarkdownPreview({ content }: { content: string }): ReactElement {
|
|
114
|
+
return (
|
|
115
|
+
<div className="markdown-preview">
|
|
116
|
+
{content.split("\n").slice(0, 240).map((line, index) => renderMarkdownLine(line, index))}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderMarkdownLine(line: string, index: number): ReactElement {
|
|
122
|
+
if (line.startsWith("### ")) {
|
|
123
|
+
return <h4 key={`${index}:${line}`}>{line.slice(4)}</h4>;
|
|
124
|
+
}
|
|
125
|
+
if (line.startsWith("## ")) {
|
|
126
|
+
return <h3 key={`${index}:${line}`}>{line.slice(3)}</h3>;
|
|
127
|
+
}
|
|
128
|
+
if (line.startsWith("# ")) {
|
|
129
|
+
return <h2 key={`${index}:${line}`}>{line.slice(2)}</h2>;
|
|
130
|
+
}
|
|
131
|
+
if (line.startsWith("- ")) {
|
|
132
|
+
return <p className="markdown-bullet" key={`${index}:${line}`}>{line}</p>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return <p key={`${index}:${line}`}>{line.length > 0 ? line : "\u00a0"}</p>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function explorerIntro(rootId: GuiFileRootId, agentsDescription: string): string {
|
|
139
|
+
if (rootId === "agents") {
|
|
140
|
+
return agentsDescription;
|
|
141
|
+
}
|
|
142
|
+
if (rootId === "config") {
|
|
143
|
+
return text.configIntro;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return rootId === "localSetup" ? text.localSetupIntro : text.gitIntro;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parentPath(relativePath: string): string {
|
|
150
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
151
|
+
parts.pop();
|
|
152
|
+
return parts.join("/");
|
|
153
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ReactElement, useState } from "react";
|
|
2
|
+
import type { InventoryColumn } from "./app-model";
|
|
3
|
+
import { appRows, installationRows, text } from "./app-model";
|
|
4
|
+
|
|
5
|
+
interface DraftInventoryRow {
|
|
6
|
+
id: string;
|
|
7
|
+
values: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let draftRowCounter = 0;
|
|
11
|
+
|
|
12
|
+
export function AppsSurface(): ReactElement {
|
|
13
|
+
return (
|
|
14
|
+
<section className="panel" aria-label={text.apps}>
|
|
15
|
+
<div className="section-heading">
|
|
16
|
+
<p className="eyebrow">{text.infrastructure}</p>
|
|
17
|
+
<h2>{text.apps}</h2>
|
|
18
|
+
<p>{text.appsIntro}</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="data-table">
|
|
21
|
+
<div className="data-row header-row"><span>{text.name}</span><span>{text.latest}</span><span>{text.channel}</span><span>{text.website}</span></div>
|
|
22
|
+
{appRows.map((row) => (
|
|
23
|
+
<div className="data-row" key={row.name}><span>{row.name}</span><span>{row.latest}</span><span>{row.channel}</span><span>{row.website}</span></div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function InstallationSurface(): ReactElement {
|
|
31
|
+
return (
|
|
32
|
+
<section className="panel" aria-label={text.installation}>
|
|
33
|
+
<div className="section-heading">
|
|
34
|
+
<p className="eyebrow">{text.setup}</p>
|
|
35
|
+
<h2>{text.installation}</h2>
|
|
36
|
+
<p>{text.installationIntro}</p>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="installation-list">
|
|
39
|
+
{installationRows.map((row) => (
|
|
40
|
+
<article className="install-row" key={row.name}>
|
|
41
|
+
<div><strong>{row.name}</strong><small>{row.scope}</small></div>
|
|
42
|
+
<TogglePill checked={row.install} label={text.install} />
|
|
43
|
+
<TogglePill checked={row.update} label={text.update} />
|
|
44
|
+
</article>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
<p className="empty-state">{text.plannedNotice}</p>
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function EditableInventorySurface({ columns, initialRows, intro, title }: {
|
|
53
|
+
columns: InventoryColumn[];
|
|
54
|
+
initialRows: Record<string, string>[];
|
|
55
|
+
intro: string;
|
|
56
|
+
title: string;
|
|
57
|
+
}): ReactElement {
|
|
58
|
+
const [draftRows, setDraftRows] = useState(() => initialRows.map((row) => createDraftRow(row)));
|
|
59
|
+
|
|
60
|
+
function updateDraftRow(rowId: string, key: string, value: string): void {
|
|
61
|
+
setDraftRows((currentRows) => currentRows.map((row) => row.id === rowId ? { ...row, values: { ...row.values, [key]: value } } : row));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addDraftRow(): void {
|
|
65
|
+
setDraftRows((currentRows) => [...currentRows, createDraftRow(emptyRow(columns))]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section className="panel" aria-label={title}>
|
|
70
|
+
<div className="section-heading split-heading">
|
|
71
|
+
<div>
|
|
72
|
+
<p className="eyebrow">{text.infrastructure}</p>
|
|
73
|
+
<h2>{title}</h2>
|
|
74
|
+
<p>{intro}</p>
|
|
75
|
+
</div>
|
|
76
|
+
<button className="secondary-action" onClick={addDraftRow} type="button">{text.addRow}</button>
|
|
77
|
+
</div>
|
|
78
|
+
<p className="notice compact-notice">{text.draftOnly}</p>
|
|
79
|
+
<div className="editable-table">
|
|
80
|
+
<div className="editable-row header-row">
|
|
81
|
+
{columns.map((column) => <span key={column.key}>{column.label}</span>)}
|
|
82
|
+
</div>
|
|
83
|
+
{draftRows.map((row) => (
|
|
84
|
+
<div className="editable-row" key={row.id}>
|
|
85
|
+
{columns.map((column) => (
|
|
86
|
+
<input
|
|
87
|
+
aria-label={`${title} ${column.label}`}
|
|
88
|
+
key={column.key}
|
|
89
|
+
onChange={(event) => updateDraftRow(row.id, column.key, event.currentTarget.value)}
|
|
90
|
+
placeholder={column.label}
|
|
91
|
+
value={row.values[column.key] ?? ""}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function TogglePill({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
102
|
+
return <span className={checked ? "toggle-pill checked" : "toggle-pill"}>{label}</span>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createDraftRow(values: Record<string, string>): DraftInventoryRow {
|
|
106
|
+
draftRowCounter += 1;
|
|
107
|
+
return { id: `draft-row-${draftRowCounter}`, values };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emptyRow(columns: InventoryColumn[]): Record<string, string> {
|
|
111
|
+
const row: Record<string, string> = {};
|
|
112
|
+
for (const column of columns) {
|
|
113
|
+
row[column.key] = "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return row;
|
|
117
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* jshint esversion: 11 */
|
|
2
|
+
import { text } from "./app-model";
|
|
3
|
+
|
|
4
|
+
export function PathActions({ pathRef }: { pathRef: string }) {
|
|
5
|
+
const copy = () => {
|
|
6
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
|
|
7
|
+
void navigator.clipboard.writeText(pathRef);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<span className="path-actions">
|
|
13
|
+
<button aria-label={text.copyPath} onClick={copy} title={text.copyPath} type="button">⧉</button>
|
|
14
|
+
<button aria-label={text.folderOpenBlocked} disabled title={text.folderOpenBlocked} type="button">⌂</button>
|
|
15
|
+
</span>
|
|
16
|
+
);
|
|
17
|
+
}
|