@rexvane/piweb 0.3.4 → 0.3.6
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 +48 -73
- package/package.json +1 -1
- package/scripts/install-build.mjs +57 -8
- package/src/components/AppShell.tsx +0 -37
- package/src/hooks/usePiWeb.ts +0 -22
- package/src/i18n.tsx +0 -6
package/README.md
CHANGED
|
@@ -14,109 +14,84 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
**PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine.
|
|
17
|
+
**PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine. Install with one command, and all your existing pi configuration, sessions, models, skills, and plugins work out of the box.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Features
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
- **Composer & Input**: Image attachments (paste, pick, drag-and-drop), 2-level model selector, model cycling forward/backward, queue management (steering / follow-up), and `/` slash commands.
|
|
23
|
-
- **Decoupled Workspaces & Sessions**: Group sessions by workspace folders, native system folder picker on Windows, rename/delete workspaces with centered confirmation modals, preserve sessions under "Ungrouped", and maintain empty workspaces independently.
|
|
24
|
-
- **Session Exploration**: Session tree branch visualization & navigation, user message draft recovery, export session logs to JSONL / HTML, and trajectory views.
|
|
25
|
-
- **Comprehensive Settings**:
|
|
26
|
-
- **General**: Tool presets (Read Only / Workspace Write / Full Access), language (zh/en), appearance, enter key behaviors, and auto-compact.
|
|
27
|
-
- **Models**: Manage 30+ built-in providers, custom providers in `models.json`, and OAuth logins (Claude, Codex, Copilot, etc.).
|
|
28
|
-
- **Plugins & Skills**: View and manage extensions and skills directly via Pi's built-in package manager.
|
|
29
|
-
- **Security & Sandboxing**: Optional HTTP Basic Auth via `PI_WEB_PASSWORD`, origin validation on write actions, and strict path boundary checks.
|
|
21
|
+
### Real-Time Streaming Chat
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
Full SSE snapshots with ordered deltas and automatic replay via `Last-Event-ID`. The thinking process and inline tool execution cards stream live.
|
|
32
24
|
|
|
33
|
-
|
|
25
|
+
<p align="center"><img src="assets/showcase.png" alt="Streaming chat with tool cards" width="840" /></p>
|
|
34
26
|
|
|
35
|
-
###
|
|
27
|
+
### Workspaces & Sessions
|
|
36
28
|
|
|
37
|
-
|
|
38
|
-
npm install -g @rexvane/piweb
|
|
39
|
-
piweb
|
|
40
|
-
```
|
|
29
|
+
Sessions grouped by project folder, with a native folder picker (Windows / macOS / Linux), rename and archive. Session tree branches, draft recovery, and JSONL / HTML export.
|
|
41
30
|
|
|
42
|
-
|
|
31
|
+
### Project Growth Tree
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
Every step the agent takes on your codebase is snapshotted: navigate the timeline round by round, see added/modified/deleted files, and open line-level diffs for any file at any step — even if the workspace has no git history of its own.
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
npm install
|
|
48
|
-
npm run dev # development server with hot reload
|
|
49
|
-
```
|
|
35
|
+
### Context Meter
|
|
50
36
|
|
|
51
|
-
|
|
37
|
+
Click the ring next to the composer to see the context window broken down into 13 segments — System Prompt, System/Custom Tools, Memory (AGENTS.md), Skills, messages by type, Compacted Data, Auto-Compact Buffer, and Free Space.
|
|
52
38
|
|
|
53
|
-
|
|
54
|
-
npm install
|
|
55
|
-
npm run build # or: npm run build:release (staged, does not touch a running build)
|
|
56
|
-
npm start # serves on http://127.0.0.1:30141 (auto-increments if busy)
|
|
57
|
-
```
|
|
39
|
+
### Model & Provider Management
|
|
58
40
|
|
|
59
|
-
|
|
41
|
+
30+ built-in providers, OAuth logins (Claude, Codex, Copilot, etc.), custom providers written to `models.json`, one-click model discovery, and a placeholder key auto-filled for local gateways like Ollama.
|
|
60
42
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
--dev Start in development mode (`next dev`; env PI_WEB_DEV=1; also used when no production build exists)
|
|
65
|
-
--no-open Do not automatically open browser (env PI_WEB_NO_OPEN=1)
|
|
66
|
-
-h, --help Show help
|
|
67
|
-
```
|
|
43
|
+
### Plugins & Skills
|
|
44
|
+
|
|
45
|
+
Install, update, and uninstall pi packages directly from the UI. Enable/disable or delete skills; every change applies to running sessions immediately.
|
|
68
46
|
|
|
69
|
-
|
|
47
|
+
### Security
|
|
70
48
|
|
|
71
|
-
|
|
49
|
+
Optional login via `PI_WEB_PASSWORD` (required for non-loopback binds), origin validation on write actions, strict path boundary checks, and tool presets (Read Only / Workspace Write / Full Access).
|
|
72
50
|
|
|
73
|
-
|
|
51
|
+
## Usage
|
|
74
52
|
|
|
75
|
-
###
|
|
53
|
+
### Install
|
|
76
54
|
|
|
77
55
|
```bash
|
|
78
|
-
npm
|
|
79
|
-
|
|
80
|
-
npm test # Service, protocol and component regressions
|
|
81
|
-
npm run check # Full check (types + tests + build)
|
|
82
|
-
npm run build:release # Validate and stage a production build without replacing the running build
|
|
56
|
+
npm install -g @rexvane/piweb
|
|
57
|
+
piweb
|
|
83
58
|
```
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
The audit follow-up is merged into this branch and has been through an isolated release plus a local production deployment. It covers the production login build, upload integrity through the Next.js proxy, per-session extension isolation and tool-policy reloads, lossless model/settings writes, composer and pending-extension-UI lifecycle, Git/Growth/diff correctness, and isolated release preparation.
|
|
60
|
+
The package registers only the `piweb` command, so it never conflicts with the official `pi` CLI. Requires Node.js ≥ 22.19.0 (24 recommended); Git is needed for growth-history features.
|
|
88
61
|
|
|
89
|
-
|
|
62
|
+
### Update
|
|
90
63
|
|
|
91
|
-
|
|
64
|
+
```bash
|
|
65
|
+
npm update -g @rexvane/piweb
|
|
66
|
+
```
|
|
92
67
|
|
|
93
|
-
|
|
68
|
+
Or install a specific version: `npm install -g @rexvane/piweb@0.3.4`.
|
|
94
69
|
|
|
95
|
-
|
|
70
|
+
### Uninstall
|
|
96
71
|
|
|
97
|
-
|
|
72
|
+
```bash
|
|
73
|
+
npm uninstall -g @rexvane/piweb
|
|
74
|
+
```
|
|
98
75
|
|
|
99
|
-
|
|
76
|
+
This removes PiWeb only — your pi data lives in `~/.pi/agent/` and is left untouched.
|
|
100
77
|
|
|
101
|
-
|
|
78
|
+
### Run
|
|
102
79
|
|
|
80
|
+
```bash
|
|
81
|
+
piweb # http://127.0.0.1:30141 (auto-increments if busy)
|
|
82
|
+
piweb -p 30143 --no-open # custom port, don't open the browser
|
|
83
|
+
piweb -H 0.0.0.0 # LAN access (requires PI_WEB_PASSWORD)
|
|
103
84
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
├─ security-service Tool presets & project trust
|
|
114
|
-
├─ plugins-service Pi package management (DefaultPackageManager) & extension registry
|
|
115
|
-
├─ workspace-store Persistent workspaces registry & native folder picker
|
|
116
|
-
▼
|
|
117
|
-
@earendil-works/pi-coding-agent (Official Pi SDK, zero engine modifications)
|
|
118
|
-
```
|
|
85
|
+
|
|
86
|
+
| Option | Description |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `-p, --port <port>` | Listen port (default 30141, env `PORT`) |
|
|
89
|
+
| `-H, --hostname <host>` | Bind address (default 127.0.0.1, env `PI_WEB_HOSTNAME`) |
|
|
90
|
+
| `--dev` | Development mode |
|
|
91
|
+
| `--no-open` | Do not auto-open the browser |
|
|
92
|
+
|
|
93
|
+
Set `PI_WEB_PASSWORD` to enable the login page and HTTP Basic Auth (user `pi`). Remote access over plain HTTP is not recommended; use HTTPS or a trusted VPN.
|
|
119
94
|
|
|
120
95
|
## License
|
|
121
96
|
|
|
122
|
-
[MIT
|
|
97
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Repository checkouts always have dev dependencies and keep `npm run dev`.
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { createRequire } from "node:module";
|
|
14
15
|
import { spawnSync } from "node:child_process";
|
|
@@ -65,12 +66,63 @@ export function resolveNextBin(root) {
|
|
|
65
66
|
* neither compiled nor mapped through the `@/*` path alias. Build in a staging
|
|
66
67
|
* directory outside `node_modules` (same drive, so the dependency links resolve)
|
|
67
68
|
* and copy the finished output back into the package.
|
|
69
|
+
*
|
|
70
|
+
* The staging directory must NOT sit inside any `node_modules`: for a global
|
|
71
|
+
* install like ~/.local/lib/node_modules/@rexvane/piweb, the grandparent IS
|
|
72
|
+
* node_modules, and Next would exclude the staged sources exactly the same way.
|
|
73
|
+
* Walk up until we are outside every node_modules segment; give up and use the
|
|
74
|
+
* system temp dir (cross-device copy is still correct, just slower).
|
|
68
75
|
*/
|
|
76
|
+
function stagingParent(root) {
|
|
77
|
+
// Split on BOTH separators: a win32 Node can see POSIX paths (and vice versa)
|
|
78
|
+
// when the package is installed through a compatibility layer or the test
|
|
79
|
+
// suite simulates foreign layouts.
|
|
80
|
+
const segments = path.dirname(root).split(/[\\/]/).filter(Boolean);
|
|
81
|
+
// Drop trailing path components while the path still contains a node_modules segment.
|
|
82
|
+
while (segments.length > 1 && segments.includes("node_modules")) {
|
|
83
|
+
segments.pop();
|
|
84
|
+
}
|
|
85
|
+
const candidate = segments.length ? path.resolve(`/${segments.join(path.sep)}`) : path.sep;
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
88
|
+
fs.accessSync(candidate, fs.constants.W_OK);
|
|
89
|
+
return candidate;
|
|
90
|
+
} catch {
|
|
91
|
+
return os.tmpdir();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Staging directory must live outside EVERY node_modules on the way up (Next excludes those paths). */
|
|
96
|
+
export function assertStagingOutsideNodeModules(root) {
|
|
97
|
+
const parent = stagingParent(root);
|
|
98
|
+
if (parent.split(/[\\/]/).includes("node_modules")) {
|
|
99
|
+
throw new Error(`staging parent is inside node_modules: ${parent}`);
|
|
100
|
+
}
|
|
101
|
+
return parent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function linkOrCopyDependencies(root, staging, log) {
|
|
105
|
+
const target = path.join(root, "node_modules");
|
|
106
|
+
const linkPath = path.join(staging, "node_modules");
|
|
107
|
+
// Windows: junction 不需要符号链接权限;跨卷时 junction 也会失败(试回退)。
|
|
108
|
+
// POSIX(macOS/Linux): 目录符号链接即可,"junction" 类型会被 libuv 拒绝
|
|
109
|
+
// (0.3.5 的 Mac 现场:symlinkSync(..., 'junction') 抛 EINVAL)。
|
|
110
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
111
|
+
try {
|
|
112
|
+
fs.symlinkSync(target, linkPath, type);
|
|
113
|
+
return;
|
|
114
|
+
} catch {
|
|
115
|
+
/* fall through to the copy */
|
|
116
|
+
}
|
|
117
|
+
// 兜底:符号链接不可用(如无权限)时整份复制依赖。慢但保证构建可解析。
|
|
118
|
+
log("[piweb] Symlink unavailable; copying dependencies into the staging build (slower)...");
|
|
119
|
+
fs.cpSync(target, linkPath, { recursive: true, verbatimSymlinks: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
69
122
|
function buildInStaging(root, nextBin, env, log, warn) {
|
|
70
|
-
const parent = path.dirname(path.dirname(root));
|
|
71
123
|
let staging;
|
|
72
124
|
try {
|
|
73
|
-
staging = fs.mkdtempSync(path.join(
|
|
125
|
+
staging = fs.mkdtempSync(path.join(stagingParent(root), ".piweb-build-"));
|
|
74
126
|
} catch {
|
|
75
127
|
staging = fs.mkdtempSync(path.join(os.tmpdir(), "piweb-build-"));
|
|
76
128
|
}
|
|
@@ -83,12 +135,7 @@ function buildInStaging(root, nextBin, env, log, warn) {
|
|
|
83
135
|
const from = path.join(root, file);
|
|
84
136
|
if (fs.existsSync(from)) fs.copyFileSync(from, path.join(staging, file));
|
|
85
137
|
}
|
|
86
|
-
|
|
87
|
-
fs.symlinkSync(path.join(root, "node_modules"), path.join(staging, "node_modules"), "junction");
|
|
88
|
-
} catch (error) {
|
|
89
|
-
warn(`[piweb] Could not link dependencies into the staging build: ${error.message}`);
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
138
|
+
linkOrCopyDependencies(root, staging, log);
|
|
92
139
|
log(`[piweb] Building in ${staging} ...`);
|
|
93
140
|
const result = spawnSync(process.execPath, [nextBin, "build", "--webpack"], { cwd: staging, stdio: "inherit", env });
|
|
94
141
|
const produced = path.join(staging, ".next", "BUILD_ID");
|
|
@@ -98,6 +145,8 @@ function buildInStaging(root, nextBin, env, log, warn) {
|
|
|
98
145
|
fs.cpSync(path.join(staging, ".next"), target, { recursive: true });
|
|
99
146
|
return fs.existsSync(path.join(target, "BUILD_ID"));
|
|
100
147
|
} finally {
|
|
148
|
+
// 复制兜底时 staging 里是整份 node_modules,必须删掉;rmSync 跟随链接删除
|
|
149
|
+
// 的是链接本身(Node 14+ 目录符号链接不穿透),真实依赖不受影响。
|
|
101
150
|
fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
102
151
|
}
|
|
103
152
|
}
|
|
@@ -81,7 +81,6 @@ export function AppShell() {
|
|
|
81
81
|
setGroupBy,
|
|
82
82
|
setOrderBy,
|
|
83
83
|
addWorkspaceByPicker,
|
|
84
|
-
addWorkspaceByPath,
|
|
85
84
|
removeWorkspace,
|
|
86
85
|
refreshModels,
|
|
87
86
|
sendCommand,
|
|
@@ -702,7 +701,6 @@ export function AppShell() {
|
|
|
702
701
|
providerNames={providerNames}
|
|
703
702
|
authByProvider={authByProvider}
|
|
704
703
|
addWorkspaceByPicker={addWorkspaceByPicker}
|
|
705
|
-
addWorkspaceByPath={addWorkspaceByPath}
|
|
706
704
|
heroModel={heroModel}
|
|
707
705
|
onSelectHeroModel={(provider, id) => {
|
|
708
706
|
setHeroModel({ provider, id });
|
|
@@ -1021,7 +1019,6 @@ function Hero({
|
|
|
1021
1019
|
providerNames,
|
|
1022
1020
|
authByProvider,
|
|
1023
1021
|
addWorkspaceByPicker,
|
|
1024
|
-
addWorkspaceByPath,
|
|
1025
1022
|
heroModel,
|
|
1026
1023
|
onSelectHeroModel,
|
|
1027
1024
|
defaultModel,
|
|
@@ -1048,7 +1045,6 @@ function Hero({
|
|
|
1048
1045
|
providerNames: Record<string, string>;
|
|
1049
1046
|
authByProvider: Record<string, boolean>;
|
|
1050
1047
|
addWorkspaceByPicker: () => Promise<string | null>;
|
|
1051
|
-
addWorkspaceByPath: (dir: string) => Promise<string | null>;
|
|
1052
1048
|
heroModel: { provider: string; id: string } | null;
|
|
1053
1049
|
onSelectHeroModel: (provider: string, id: string) => void;
|
|
1054
1050
|
defaultModel?: ModelChoice;
|
|
@@ -1058,8 +1054,6 @@ function Hero({
|
|
|
1058
1054
|
}) {
|
|
1059
1055
|
const { t } = useI18n();
|
|
1060
1056
|
const [wsMenu, setWsMenu] = useState(false);
|
|
1061
|
-
const [wsPathDraft, setWsPathDraft] = useState("");
|
|
1062
|
-
const [wsPathError, setWsPathError] = useState<string | null>(null);
|
|
1063
1057
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
1064
1058
|
|
|
1065
1059
|
useEffect(() => {
|
|
@@ -1081,19 +1075,6 @@ function Hero({
|
|
|
1081
1075
|
}
|
|
1082
1076
|
};
|
|
1083
1077
|
|
|
1084
|
-
// 手动输入路径添加(原生选择器不可用 / 无桌面环境兜底)
|
|
1085
|
-
const addByPath = async () => {
|
|
1086
|
-
const p = await addWorkspaceByPath(wsPathDraft);
|
|
1087
|
-
if (p) {
|
|
1088
|
-
setWsPathDraft("");
|
|
1089
|
-
setWsPathError(null);
|
|
1090
|
-
setCwd(p);
|
|
1091
|
-
setWsMenu(false);
|
|
1092
|
-
} else {
|
|
1093
|
-
setWsPathError(t.workspacePathInvalid);
|
|
1094
|
-
}
|
|
1095
|
-
};
|
|
1096
|
-
|
|
1097
1078
|
const currentLabel = cwd ? (getWorkspaceName ? getWorkspaceName(cwd) : basename(cwd)) : t.startWith;
|
|
1098
1079
|
|
|
1099
1080
|
return (
|
|
@@ -1155,24 +1136,6 @@ function Hero({
|
|
|
1155
1136
|
<IconProjectAddOutline16 size={14} style={{ flex: "none", color: "var(--dsw-label-tertiary)" }} />
|
|
1156
1137
|
{t.addWorkspace}
|
|
1157
1138
|
</button>
|
|
1158
|
-
<div className="flex items-center gap-1.5 px-2 py-1.5" style={{ fontSize: 12.5 }}>
|
|
1159
|
-
<input
|
|
1160
|
-
className="min-w-0 flex-1 rounded-lg px-2 py-1"
|
|
1161
|
-
style={{ border: "0.5px solid var(--dsw-border-l3)", background: "var(--dsw-input-bg, transparent)", color: "inherit" }}
|
|
1162
|
-
placeholder={t.workspacePathPlaceholder}
|
|
1163
|
-
value={wsPathDraft}
|
|
1164
|
-
onChange={(e) => { setWsPathDraft(e.target.value); setWsPathError(null); }}
|
|
1165
|
-
onKeyDown={(e) => {
|
|
1166
|
-
if (e.key === "Enter") { e.preventDefault(); void addByPath(); }
|
|
1167
|
-
}}
|
|
1168
|
-
/>
|
|
1169
|
-
<button type="button" className="pw-chip" style={{ flex: "none" }} disabled={!wsPathDraft.trim()} onClick={() => void addByPath()}>
|
|
1170
|
-
{t.workspacePathAdd}
|
|
1171
|
-
</button>
|
|
1172
|
-
</div>
|
|
1173
|
-
{wsPathError && (
|
|
1174
|
-
<div className="px-3.5 pb-1.5" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{wsPathError}</div>
|
|
1175
|
-
)}
|
|
1176
1139
|
</div>
|
|
1177
1140
|
)}
|
|
1178
1141
|
</div>
|
package/src/hooks/usePiWeb.ts
CHANGED
|
@@ -496,27 +496,6 @@ export function usePiWeb() {
|
|
|
496
496
|
}
|
|
497
497
|
}, [refreshWorkspaces]);
|
|
498
498
|
|
|
499
|
-
/** 手动输入路径添加工作区(原生选择器不可用 / 无桌面环境时的兜底) */
|
|
500
|
-
const addWorkspaceByPath = useCallback(async (dir: string): Promise<string | null> => {
|
|
501
|
-
const trimmed = dir.trim().replace(/^["']|["']$/g, "");
|
|
502
|
-
if (!trimmed) return null;
|
|
503
|
-
try {
|
|
504
|
-
const r = await fetch("/api/workspaces", {
|
|
505
|
-
method: "POST",
|
|
506
|
-
headers: { "Content-Type": "application/json" },
|
|
507
|
-
body: JSON.stringify({ action: "add", path: trimmed }),
|
|
508
|
-
});
|
|
509
|
-
const j = await r.json();
|
|
510
|
-
if (!r.ok || !j.success) throw new Error(j.error || `request failed (${r.status})`);
|
|
511
|
-
setAddedWorkspaces(j.data?.workspaces ?? []);
|
|
512
|
-
await refreshWorkspaces();
|
|
513
|
-
return trimmed;
|
|
514
|
-
} catch (error) {
|
|
515
|
-
const message = error instanceof Error ? error.message : "failed to add workspace";
|
|
516
|
-
setState((current) => ({ ...current, error: message }));
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
}, [refreshWorkspaces]);
|
|
520
499
|
|
|
521
500
|
const removeWorkspace = useCallback(
|
|
522
501
|
async (dir: string) => {
|
|
@@ -906,7 +885,6 @@ export function usePiWeb() {
|
|
|
906
885
|
setGroupBy,
|
|
907
886
|
setOrderBy,
|
|
908
887
|
addWorkspaceByPicker,
|
|
909
|
-
addWorkspaceByPath,
|
|
910
888
|
removeWorkspace,
|
|
911
889
|
refreshModels,
|
|
912
890
|
sendCommand,
|
package/src/i18n.tsx
CHANGED
|
@@ -145,9 +145,6 @@ const zh = {
|
|
|
145
145
|
workspaces: "工作区",
|
|
146
146
|
searchSessions: "搜索会话...",
|
|
147
147
|
addWorkspace: "添加工作区",
|
|
148
|
-
workspacePathPlaceholder: "输入路径添加…",
|
|
149
|
-
workspacePathInvalid: "路径无效或目录不存在",
|
|
150
|
-
workspacePathAdd: "添加",
|
|
151
148
|
groupBy: "分组方式",
|
|
152
149
|
sortBy: "排序方式",
|
|
153
150
|
byWorkspace: "按工作区",
|
|
@@ -678,9 +675,6 @@ const en: Dict = {
|
|
|
678
675
|
workspaces: "Workspaces",
|
|
679
676
|
searchSessions: "Search sessions...",
|
|
680
677
|
addWorkspace: "Add workspace",
|
|
681
|
-
workspacePathPlaceholder: "Type a path to add…",
|
|
682
|
-
workspacePathInvalid: "Invalid path or directory does not exist",
|
|
683
|
-
workspacePathAdd: "Add",
|
|
684
678
|
groupBy: "Group by",
|
|
685
679
|
sortBy: "Sort by",
|
|
686
680
|
byWorkspace: "Workspace",
|