@xpufx/paseo-plugin-updates 0.1.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 +21 -0
- package/README.md +108 -0
- package/client/orphans.ts +37 -0
- package/client/updates.tsx +510 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/package.json +37 -0
- package/paseo-plugin.json +4 -0
- package/server/__node-test-shim.ts +14 -0
- package/server/updates.ts +1332 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/updates.ts +239 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xpufx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# plugin-updates
|
|
2
|
+
|
|
3
|
+
Git-source update monitor and updater for [Paseo](https://github.com/getpaseo/paseo) plugins (v0.8+).
|
|
4
|
+
|
|
5
|
+
<table align="center">
|
|
6
|
+
<tr>
|
|
7
|
+
<td align="center"><strong>main</strong><br><img src="screenshots/plugin-updates-main.jpg" alt="Plugin updates — main view" width="300"></td>
|
|
8
|
+
<td align="center"><strong>updating</strong><br><img src="screenshots/plugin-updates-action.jpg" alt="Plugin updates — updating" width="300"></td>
|
|
9
|
+
</tr>
|
|
10
|
+
</table>
|
|
11
|
+
|
|
12
|
+
Adds a header button to every workspace that opens a popover listing the installed plugins. Each row reports whether the plugin's installed git source is current, behind, pinned, missing, or has no upstream, and offers a one-click update when a pullable change exists.
|
|
13
|
+
|
|
14
|
+
Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> **Prerequisites & Platform Support**:
|
|
18
|
+
> - The system `git` CLI must be available, because update checks and pulls shell out to `git`.
|
|
19
|
+
> - Developed and tested primarily on **Linux**.
|
|
20
|
+
|
|
21
|
+
## Core Capabilities
|
|
22
|
+
|
|
23
|
+
### 1. Workspace Header Button
|
|
24
|
+
- Registers a header button once per workspace, titled **Plugin updates**, that opens a popover listing every installed plugin.
|
|
25
|
+
- The button shows a refresh glyph while a check is running, and a status dot in the corner: green when everything is fresh, amber while checking or when an update is available, and red when a check fails.
|
|
26
|
+
|
|
27
|
+
### 2. Per-plugin Git Status
|
|
28
|
+
Each row resolves the plugin to its git repository root, ref, and subdirectory, then probes the remote to classify the install:
|
|
29
|
+
|
|
30
|
+
- **current**: the local commit or subdirectory tree matches the tracked ref, or the local checkout is ahead of the remote.
|
|
31
|
+
- **behind**: the remote ref has commits the local checkout does not, so an update is available.
|
|
32
|
+
- **pinned**: the install is pinned to a fixed tag or commit. Pinned installs are report only; nothing is ever pulled for them.
|
|
33
|
+
- **no-upstream**: no upstream remote is configured, or the tracked branch or tag no longer exists on the remote.
|
|
34
|
+
- **missing**: the remote is reachable but the plugin subdirectory does not exist at the tracked ref.
|
|
35
|
+
- **unpinned**: a detached HEAD, or a managed install with no recorded ref. Report only.
|
|
36
|
+
- **not-a-repo**: the plugin is not a git checkout, so no git update is possible.
|
|
37
|
+
- **error**: the check itself failed, for example because the remote was unreachable.
|
|
38
|
+
|
|
39
|
+
### 3. Per-subdirectory Scoping
|
|
40
|
+
- Plugins installed from a monorepo subpath track only that subdirectory tree. A commit that touches other paths in the same repository does not register as an update.
|
|
41
|
+
- The row shows the resolved **Subdir** (`(repo root)` when the plugin is the repository root), alongside the local and remote tree hashes and the tracked ref.
|
|
42
|
+
|
|
43
|
+
### 4. Source Links
|
|
44
|
+
- Each row resolves a browsable source URL from the remote the plugin was installed from, falling back to `package.json` `repository` and `homepage` metadata when no install remote exists.
|
|
45
|
+
- GitHub, GitLab, and unknown forges get an appropriate glyph, and links deep-link into the subdirectory for GitHub (`/tree/<ref>/<subdir>`) and Forgejo/Gitea-style (`/src/branch/<ref>/<subdir>`) hosts.
|
|
46
|
+
- A `dirty` badge appears on a directory install when the plugin scope has uncommitted changes.
|
|
47
|
+
|
|
48
|
+
### 5. Update Action
|
|
49
|
+
- Available updates show an **Update** button in the row. After a failed pull that needs an override, the button becomes **Force update**.
|
|
50
|
+
- **git-managed installs** delegate to `paseo plugin update <id>`.
|
|
51
|
+
- **directory (path-linked) installs** resolve the repository root and run a fast-forward `git pull`. The pull is refused when the working tree is dirty unless forced, and refused when the local branch has diverged from its upstream.
|
|
52
|
+
- After a successful pull, every plugin that shares the same repository root is reloaded.
|
|
53
|
+
- When more than one plugin has an update, an **Update all** button appears. If any failure requires an override, **Force update all** is offered instead.
|
|
54
|
+
|
|
55
|
+
### 6. Orphaned Directory Flagging
|
|
56
|
+
- Directories left behind in Paseo's managed plugin location by an uninstalled or failed install are listed in a separate **Orphaned directories** section with a name and path.
|
|
57
|
+
- Orphans are reported only; they are never probed or updated.
|
|
58
|
+
|
|
59
|
+
### 7. Live Refresh
|
|
60
|
+
- The popover re-checks on a 30 second interval and on demand through the refresh button, and reports the available update count in its header.
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
Install from the public monorepo subpath (GitHub shorthand):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
paseo plugin add xpufx/paseo --path plugins/plugin-updates
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
> [!NOTE]
|
|
71
|
+
> Bare `owner/repo` shorthand resolves against **GitHub** only. For any other forge, pass the full git URL instead, for example `https://git.example.com/owner/repo`.
|
|
72
|
+
|
|
73
|
+
For local development:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
git clone https://github.com/xpufx/paseo.git
|
|
77
|
+
cd paseo
|
|
78
|
+
paseo plugin add ./plugins/plugin-updates
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Usage & Configuration
|
|
82
|
+
|
|
83
|
+
- No settings are required. The header button registers automatically in each workspace that has an agent session.
|
|
84
|
+
- Open the button to see installed plugins, their status, and available updates, then use the row **Update** button or **Update all** to pull and reload.
|
|
85
|
+
- Checks run against the plugin's install remote only, so a fork or mirror installed as the source is what gets compared.
|
|
86
|
+
- Pulls run with `--ff-only`. A dirty working tree or a diverged branch must be reconciled manually before an update can apply.
|
|
87
|
+
|
|
88
|
+
## Limitations
|
|
89
|
+
|
|
90
|
+
- **Local git installs are report-only.** With no upstream remote or recorded ref, there is nothing to compare against or pull, so no **Update** button is offered for them. Use a git remote + subpath to get updates.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
# Typecheck
|
|
96
|
+
npm run typecheck --workspace=plugins/plugin-updates
|
|
97
|
+
|
|
98
|
+
# Typecheck plus the test suite (tsx --test)
|
|
99
|
+
npm run test --workspace=plugins/plugin-updates
|
|
100
|
+
|
|
101
|
+
# Reload in a running Paseo daemon
|
|
102
|
+
paseo plugin reload plugin-updates
|
|
103
|
+
paseo plugin logs plugin-updates
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PluginUpdate } from "../shared/updates";
|
|
2
|
+
|
|
3
|
+
export interface OrphanEntry {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OrphanSection {
|
|
9
|
+
title: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
items: OrphanEntry[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function orphanName(id: string): string {
|
|
15
|
+
const prefix = "orphaned:";
|
|
16
|
+
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function orphanDetail(count: number): string {
|
|
20
|
+
return count === 1
|
|
21
|
+
? "Leftover managed directory from an uninstalled or failed install — not probed"
|
|
22
|
+
: "Leftover managed directories from uninstalled or failed installs — not probed";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function partitionedRows(plugins: PluginUpdate[]): PluginUpdate[] {
|
|
26
|
+
return plugins.filter((plugin) => plugin.status !== "orphaned");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildOrphanSection(plugins: PluginUpdate[]): OrphanSection | null {
|
|
30
|
+
const orphans = plugins.filter((plugin) => plugin.status === "orphaned");
|
|
31
|
+
if (orphans.length === 0) return null;
|
|
32
|
+
return {
|
|
33
|
+
title: "Orphaned directories",
|
|
34
|
+
detail: orphanDetail(orphans.length),
|
|
35
|
+
items: orphans.map((plugin) => ({ name: orphanName(plugin.id), path: plugin.path })),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginButtonContentProps,
|
|
3
|
+
PluginButtonIconProps,
|
|
4
|
+
PluginButtonRegistration,
|
|
5
|
+
} from "@getpaseo/plugin/client";
|
|
6
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
7
|
+
import { Icon, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
|
|
8
|
+
import {
|
|
9
|
+
Badge,
|
|
10
|
+
Button,
|
|
11
|
+
Card,
|
|
12
|
+
EmptyState,
|
|
13
|
+
KeyValue,
|
|
14
|
+
KeyValueGroup,
|
|
15
|
+
PluginThemeProvider,
|
|
16
|
+
ProgressBar,
|
|
17
|
+
Row,
|
|
18
|
+
SectionHeader,
|
|
19
|
+
StatusDot,
|
|
20
|
+
usePluginTheme,
|
|
21
|
+
} from "paseo-plugin-helper/client";
|
|
22
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
23
|
+
import { useEffect, useState } from "react";
|
|
24
|
+
import { ActivityIndicator, Linking, Text, View } from "react-native";
|
|
25
|
+
import {
|
|
26
|
+
pluginUpdatesCheckRpc,
|
|
27
|
+
pluginUpdatesUpdateAllRpc,
|
|
28
|
+
pluginUpdatesUpdateRpc,
|
|
29
|
+
resolveSourceRef,
|
|
30
|
+
shortHash,
|
|
31
|
+
type PluginUpdate,
|
|
32
|
+
} from "../shared/updates";
|
|
33
|
+
import { buildOrphanSection, partitionedRows, type OrphanSection } from "./orphans";
|
|
34
|
+
|
|
35
|
+
const QUERY_KEY = ["plugin-updates"];
|
|
36
|
+
const POLL_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
function usePluginUpdates(workspaceId: string) {
|
|
39
|
+
const check = useRpc(pluginUpdatesCheckRpc);
|
|
40
|
+
return useQuery({
|
|
41
|
+
queryKey: [...QUERY_KEY, workspaceId],
|
|
42
|
+
queryFn: () => check({ workspaceId }),
|
|
43
|
+
refetchInterval: POLL_MS,
|
|
44
|
+
retry: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function statusVariant(status: PluginUpdate["status"]): "success" | "warning" | "danger" | "neutral" {
|
|
49
|
+
if (status === "behind") return "warning";
|
|
50
|
+
if (status === "error") return "danger";
|
|
51
|
+
if (status === "current") return "success";
|
|
52
|
+
return "neutral";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fallbackDetail(plugin: PluginUpdate): string {
|
|
56
|
+
if (plugin.detail) return plugin.detail;
|
|
57
|
+
switch (plugin.status) {
|
|
58
|
+
case "behind":
|
|
59
|
+
return "Update available";
|
|
60
|
+
case "current":
|
|
61
|
+
return "Up to date";
|
|
62
|
+
case "pinned":
|
|
63
|
+
return "Pinned to a fixed ref — report only";
|
|
64
|
+
case "not-a-repo":
|
|
65
|
+
return "Not a git repository — no git update possible";
|
|
66
|
+
case "unpinned":
|
|
67
|
+
return "Detached HEAD — no upstream to compare (report only)";
|
|
68
|
+
case "no-upstream":
|
|
69
|
+
return "No upstream remote configured (report only)";
|
|
70
|
+
case "missing":
|
|
71
|
+
return "Plugin does not exist at the source it was installed from.";
|
|
72
|
+
case "orphaned":
|
|
73
|
+
return "Leftover managed directory from a failed install — not probed";
|
|
74
|
+
case "checking":
|
|
75
|
+
return "Checking…";
|
|
76
|
+
default:
|
|
77
|
+
return plugin.error || "Check failed";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reportOnlyNote(plugin: PluginUpdate): string | null {
|
|
82
|
+
const status = plugin.status;
|
|
83
|
+
if (status === "not-a-repo") return "Not a git checkout — update it from the workspace instead.";
|
|
84
|
+
if (status === "orphaned") return "Orphaned managed directory — safe to remove if unused.";
|
|
85
|
+
if (status === "pinned") {
|
|
86
|
+
return plugin.refKind === "tag"
|
|
87
|
+
? "Pinned to a tag — tags are never auto-updated. Reinstall to move to another tag."
|
|
88
|
+
: "Pinned by commit — immutable, no update is ever offered.";
|
|
89
|
+
}
|
|
90
|
+
if (status === "unpinned" || status === "no-upstream") return "Report only — no upstream remote to update from.";
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function refLabel(plugin: PluginUpdate): string {
|
|
95
|
+
if (!plugin.ref) return "-";
|
|
96
|
+
return plugin.refKind ? `${plugin.refKind} · ${plugin.ref}` : plugin.ref;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hashValue(value: string | null): string | null {
|
|
100
|
+
return value ? shortHash(value) : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function PluginRow({
|
|
104
|
+
plugin,
|
|
105
|
+
updating,
|
|
106
|
+
forceNeeded,
|
|
107
|
+
onUpdate,
|
|
108
|
+
}: {
|
|
109
|
+
plugin: PluginUpdate;
|
|
110
|
+
updating: boolean;
|
|
111
|
+
forceNeeded: boolean;
|
|
112
|
+
onUpdate: (pluginId: string, force: boolean) => void;
|
|
113
|
+
}) {
|
|
114
|
+
const { colors, typography, padding } = usePluginTheme();
|
|
115
|
+
const detail = fallbackDetail(plugin);
|
|
116
|
+
const note = reportOnlyNote(plugin);
|
|
117
|
+
const isOrphan = plugin.status === "orphaned";
|
|
118
|
+
const version = hashValue(plugin.workingTree ?? plugin.localTree);
|
|
119
|
+
const source = resolveSourceRef(plugin.sourceUrl);
|
|
120
|
+
// Errors carry actionable text; clamp only the one-line status sentences.
|
|
121
|
+
const detailLines = plugin.error ? undefined : 1;
|
|
122
|
+
return (
|
|
123
|
+
<Card variant="elevated">
|
|
124
|
+
<View style={{ gap: padding.gap }}>
|
|
125
|
+
<Row align="center">
|
|
126
|
+
<StatusDot variant={statusVariant(plugin.status)} pulse={plugin.status === "checking"} />
|
|
127
|
+
<Text style={{ color: colors.foreground, ...typography.bodyStrong, flex: 1 }}>{plugin.id}</Text>
|
|
128
|
+
{source ? (
|
|
129
|
+
<Button
|
|
130
|
+
icon={source.icon}
|
|
131
|
+
label={source.label}
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
accessibilityLabel="Open source"
|
|
135
|
+
onPress={() => {
|
|
136
|
+
void Linking.openURL(source.url).catch(() => {});
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
{plugin.dirty === true && !isOrphan ? <Badge label="dirty" variant="warning" dot /> : null}
|
|
141
|
+
{plugin.updateAvailable && !isOrphan ? (
|
|
142
|
+
<Button
|
|
143
|
+
label={forceNeeded ? "Force update" : "Update"}
|
|
144
|
+
variant={forceNeeded ? "primary" : "secondary"}
|
|
145
|
+
size="sm"
|
|
146
|
+
loading={updating}
|
|
147
|
+
disabled={updating}
|
|
148
|
+
onPress={() => onUpdate(plugin.id, forceNeeded)}
|
|
149
|
+
/>
|
|
150
|
+
) : null}
|
|
151
|
+
</Row>
|
|
152
|
+
<Text selectable numberOfLines={detailLines} style={{ color: colors.foregroundMuted, ...typography.caption }}>
|
|
153
|
+
{detail}
|
|
154
|
+
</Text>
|
|
155
|
+
{note ? (
|
|
156
|
+
<Text style={{ color: colors.foregroundMuted, ...typography.caption }}>{note}</Text>
|
|
157
|
+
) : null}
|
|
158
|
+
{isOrphan ? null : (
|
|
159
|
+
<>
|
|
160
|
+
<KeyValueGroup columns={2} collapse="never" minColumnWidth={180} gap={0}>
|
|
161
|
+
<KeyValue
|
|
162
|
+
layout="inline"
|
|
163
|
+
label="Version"
|
|
164
|
+
value={version}
|
|
165
|
+
subValue={plugin.workingTree ? "working tree" : undefined}
|
|
166
|
+
mono
|
|
167
|
+
copyable
|
|
168
|
+
/>
|
|
169
|
+
<KeyValue layout="inline" label="Remote" value={hashValue(plugin.remoteTree)} mono copyable />
|
|
170
|
+
<KeyValue layout="inline" label="Ref" value={refLabel(plugin)} truncate="end" />
|
|
171
|
+
<KeyValue
|
|
172
|
+
layout="inline"
|
|
173
|
+
label="Subdir"
|
|
174
|
+
value={plugin.subdir === "" ? "(repo root)" : plugin.subdir}
|
|
175
|
+
truncate="path"
|
|
176
|
+
/>
|
|
177
|
+
</KeyValueGroup>
|
|
178
|
+
{plugin.latestChange?.subject ? (
|
|
179
|
+
<Text selectable numberOfLines={1} style={{ color: colors.foregroundMuted, ...typography.caption }}>
|
|
180
|
+
Latest remote change: {plugin.latestChange.subject}
|
|
181
|
+
{plugin.latestChange.date ? ` (${plugin.latestChange.date})` : ""}
|
|
182
|
+
</Text>
|
|
183
|
+
) : null}
|
|
184
|
+
</>
|
|
185
|
+
)}
|
|
186
|
+
</View>
|
|
187
|
+
</Card>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function OrphanSection({ section }: { section: OrphanSection }) {
|
|
192
|
+
const { colors, typography } = usePluginTheme();
|
|
193
|
+
return (
|
|
194
|
+
<View style={{ gap: 6 }}>
|
|
195
|
+
<SectionHeader title={section.title} count={section.items.length} />
|
|
196
|
+
<Text style={{ color: colors.foregroundMuted, ...typography.caption }}>{section.detail}</Text>
|
|
197
|
+
<View style={{ gap: 6 }}>
|
|
198
|
+
{section.items.map((item) => (
|
|
199
|
+
<View key={item.name} style={{ gap: 1 }}>
|
|
200
|
+
<Text selectable style={{ color: colors.foreground, ...typography.bodyStrong }}>
|
|
201
|
+
{item.name}
|
|
202
|
+
</Text>
|
|
203
|
+
<Text selectable numberOfLines={1} style={{ color: colors.foregroundMuted, ...typography.caption }}>
|
|
204
|
+
{item.path}
|
|
205
|
+
</Text>
|
|
206
|
+
</View>
|
|
207
|
+
))}
|
|
208
|
+
</View>
|
|
209
|
+
</View>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function PluginUpdatesIconInner(props: PluginButtonIconProps) {
|
|
214
|
+
const { data, isFetching, isError } = usePluginUpdates(props.workspaceId);
|
|
215
|
+
const stale = data?.plugins.some((plugin) => plugin.updateAvailable) ?? false;
|
|
216
|
+
const failed = isError || data?.plugins.some((plugin) => plugin.status === "error") === true;
|
|
217
|
+
const color = failed
|
|
218
|
+
? props.theme.colors.statusDanger || "#ef4444"
|
|
219
|
+
: stale
|
|
220
|
+
? props.theme.colors.statusWarning || "#f59e0b"
|
|
221
|
+
: props.color;
|
|
222
|
+
return (
|
|
223
|
+
<View style={{ width: props.size, height: props.size, alignItems: "center", justifyContent: "center" }}>
|
|
224
|
+
<Icon name={isFetching ? "RefreshCw" : "Package"} size={props.size} color={color} />
|
|
225
|
+
<View
|
|
226
|
+
accessibilityLabel={isFetching ? "Checking plugin updates" : stale ? "Plugin updates available" : "Plugins fresh"}
|
|
227
|
+
style={{
|
|
228
|
+
position: "absolute",
|
|
229
|
+
right: -1,
|
|
230
|
+
top: -1,
|
|
231
|
+
width: 7,
|
|
232
|
+
height: 7,
|
|
233
|
+
borderRadius: 4,
|
|
234
|
+
backgroundColor: isFetching
|
|
235
|
+
? props.theme.colors.statusWarning || "#f59e0b"
|
|
236
|
+
: failed
|
|
237
|
+
? props.theme.colors.statusDanger || "#ef4444"
|
|
238
|
+
: stale
|
|
239
|
+
? props.theme.colors.statusWarning || "#f59e0b"
|
|
240
|
+
: props.theme.colors.statusSuccess || "#22c55e",
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
</View>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function PluginUpdatesIcon(props: PluginButtonIconProps) {
|
|
248
|
+
return (
|
|
249
|
+
<PluginThemeProvider theme={{ colors: props.theme.colors }}>
|
|
250
|
+
<PluginUpdatesIconInner {...props} />
|
|
251
|
+
</PluginThemeProvider>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface Failure {
|
|
256
|
+
pluginId: string;
|
|
257
|
+
error: string;
|
|
258
|
+
requiresForce: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function PluginUpdatesPopoverInner(props: PluginButtonContentProps) {
|
|
262
|
+
const { colors, typography, padding } = usePluginTheme();
|
|
263
|
+
const toast = useToast();
|
|
264
|
+
const queryClient = useQueryClient();
|
|
265
|
+
const query = usePluginUpdates(props.workspaceId);
|
|
266
|
+
const update = useRpc(pluginUpdatesUpdateRpc);
|
|
267
|
+
const updateAll = useRpc(pluginUpdatesUpdateAllRpc);
|
|
268
|
+
const [activeUpdate, setActiveUpdate] = useState<string | "all" | null>(null);
|
|
269
|
+
const [progress, setProgress] = useState(0);
|
|
270
|
+
const [failures, setFailures] = useState<Failure[]>([]);
|
|
271
|
+
|
|
272
|
+
const plugins = query.data?.plugins ?? [];
|
|
273
|
+
const rows = partitionedRows(plugins);
|
|
274
|
+
const orphanSection = buildOrphanSection(plugins);
|
|
275
|
+
const staleCount = plugins.filter((plugin) => plugin.updateAvailable).length;
|
|
276
|
+
const forceCount = failures.filter((failure) => failure.requiresForce).length;
|
|
277
|
+
|
|
278
|
+
const refresh = async () => {
|
|
279
|
+
try {
|
|
280
|
+
setFailures([]);
|
|
281
|
+
await query.refetch();
|
|
282
|
+
} catch (error) {
|
|
283
|
+
toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const recordFailure = (failure: Failure) => {
|
|
288
|
+
setFailures((current) => [
|
|
289
|
+
...current.filter((item) => item.pluginId !== failure.pluginId),
|
|
290
|
+
failure,
|
|
291
|
+
]);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const clearFailure = (pluginId: string) => {
|
|
295
|
+
setFailures((current) => current.filter((failure) => failure.pluginId !== pluginId));
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const runUpdate = async (pluginId: string, force = false) => {
|
|
299
|
+
setActiveUpdate(pluginId);
|
|
300
|
+
setProgress(0.25);
|
|
301
|
+
try {
|
|
302
|
+
const result = await update({ workspaceId: props.workspaceId, pluginId, force });
|
|
303
|
+
if (result.status === "error") {
|
|
304
|
+
recordFailure({
|
|
305
|
+
pluginId,
|
|
306
|
+
error: result.error || "Update failed",
|
|
307
|
+
requiresForce: result.requiresForce === true,
|
|
308
|
+
});
|
|
309
|
+
toast.show(result.error || `Failed to update ${pluginId}`, { variant: "error" });
|
|
310
|
+
} else {
|
|
311
|
+
clearFailure(pluginId);
|
|
312
|
+
toast.show(`${pluginId} updated`, { variant: "success" });
|
|
313
|
+
}
|
|
314
|
+
setProgress(1);
|
|
315
|
+
await queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, props.workspaceId] });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
|
|
318
|
+
} finally {
|
|
319
|
+
setActiveUpdate(null);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const runUpdateAll = async (force = false) => {
|
|
324
|
+
setActiveUpdate("all");
|
|
325
|
+
setProgress(0.1);
|
|
326
|
+
try {
|
|
327
|
+
const result = await updateAll({ workspaceId: props.workspaceId, force });
|
|
328
|
+
const failed = result.results.filter((item) => item.status === "error");
|
|
329
|
+
setFailures(
|
|
330
|
+
failed.map((item) => ({
|
|
331
|
+
pluginId: item.pluginId,
|
|
332
|
+
error: item.error || "Update failed",
|
|
333
|
+
requiresForce: item.requiresForce === true,
|
|
334
|
+
})),
|
|
335
|
+
);
|
|
336
|
+
setProgress(1);
|
|
337
|
+
if (failed.length > 0) {
|
|
338
|
+
toast.show(`${failed.length} plugin update${failed.length === 1 ? "" : "s"} failed`, {
|
|
339
|
+
variant: "error",
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
toast.show("All plugins updated", { variant: "success" });
|
|
343
|
+
}
|
|
344
|
+
await queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, props.workspaceId] });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
|
|
347
|
+
} finally {
|
|
348
|
+
setActiveUpdate(null);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (query.isError) {
|
|
354
|
+
toast.show("Unable to check plugin updates", { variant: "error" });
|
|
355
|
+
}
|
|
356
|
+
}, [query.isError, toast]);
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<PluginThemeProvider theme={props.theme} layout={props.layout}>
|
|
360
|
+
<ScrollView
|
|
361
|
+
contentContainerStyle={{
|
|
362
|
+
paddingHorizontal: padding.horizontal,
|
|
363
|
+
paddingVertical: padding.vertical,
|
|
364
|
+
gap: padding.gap,
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<Row align="center">
|
|
368
|
+
<View style={{ flex: 1 }}>
|
|
369
|
+
<Text style={{ color: colors.foreground, ...typography.heading }}>Plugin updates</Text>
|
|
370
|
+
<Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
|
|
371
|
+
{query.isFetching ? "Checking installed plugins…" : `${staleCount} update${staleCount === 1 ? "" : "s"} available`}
|
|
372
|
+
</Text>
|
|
373
|
+
</View>
|
|
374
|
+
<Button
|
|
375
|
+
icon="RefreshCw"
|
|
376
|
+
variant="ghost"
|
|
377
|
+
size="sm"
|
|
378
|
+
loading={query.isFetching}
|
|
379
|
+
disabled={query.isFetching || activeUpdate !== null}
|
|
380
|
+
accessibilityLabel="Refresh plugin update check"
|
|
381
|
+
onPress={refresh}
|
|
382
|
+
/>
|
|
383
|
+
</Row>
|
|
384
|
+
{activeUpdate ? <ProgressBar value={progress} autoStatusColor label="Updating plugins" showValueText /> : null}
|
|
385
|
+
{query.isLoading && !query.data ? (
|
|
386
|
+
<View style={{ alignItems: "center", paddingVertical: padding.vertical * 2 }}>
|
|
387
|
+
<ActivityIndicator color={colors.foregroundMuted} />
|
|
388
|
+
</View>
|
|
389
|
+
) : plugins.length === 0 ? (
|
|
390
|
+
<EmptyState title="No installed plugins" description="Paseo did not report any installed plugins." />
|
|
391
|
+
) : (
|
|
392
|
+
<>
|
|
393
|
+
{rows.map((plugin) => (
|
|
394
|
+
<PluginRow
|
|
395
|
+
key={plugin.id}
|
|
396
|
+
plugin={plugin}
|
|
397
|
+
updating={activeUpdate === plugin.id}
|
|
398
|
+
forceNeeded={failures.some((failure) => failure.pluginId === plugin.id && failure.requiresForce)}
|
|
399
|
+
onUpdate={runUpdate}
|
|
400
|
+
/>
|
|
401
|
+
))}
|
|
402
|
+
{orphanSection ? <OrphanSection section={orphanSection} /> : null}
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
405
|
+
{failures.map((failure) => (
|
|
406
|
+
<Card key={`failure-${failure.pluginId}`} variant="elevated">
|
|
407
|
+
<View style={{ gap: padding.gap }}>
|
|
408
|
+
<Row align="center">
|
|
409
|
+
<StatusDot variant="danger" />
|
|
410
|
+
<Text style={{ color: colors.statusDanger, ...typography.bodyStrong, flex: 1 }}>
|
|
411
|
+
{failure.pluginId} update failed
|
|
412
|
+
</Text>
|
|
413
|
+
{failure.requiresForce ? (
|
|
414
|
+
<Button
|
|
415
|
+
label="Force"
|
|
416
|
+
variant="secondary"
|
|
417
|
+
size="sm"
|
|
418
|
+
loading={activeUpdate === failure.pluginId}
|
|
419
|
+
disabled={activeUpdate !== null}
|
|
420
|
+
onPress={() => runUpdate(failure.pluginId, true)}
|
|
421
|
+
/>
|
|
422
|
+
) : null}
|
|
423
|
+
</Row>
|
|
424
|
+
<Text selectable style={{ color: colors.foregroundMuted, ...typography.caption }}>
|
|
425
|
+
{failure.error}
|
|
426
|
+
</Text>
|
|
427
|
+
</View>
|
|
428
|
+
</Card>
|
|
429
|
+
))}
|
|
430
|
+
{forceCount > 0 ? (
|
|
431
|
+
<Button
|
|
432
|
+
label="Force update all"
|
|
433
|
+
variant="secondary"
|
|
434
|
+
icon="AlertTriangle"
|
|
435
|
+
loading={activeUpdate === "all"}
|
|
436
|
+
disabled={activeUpdate !== null}
|
|
437
|
+
onPress={() => runUpdateAll(true)}
|
|
438
|
+
/>
|
|
439
|
+
) : staleCount > 1 ? (
|
|
440
|
+
<Button
|
|
441
|
+
label={`Update all (${staleCount})`}
|
|
442
|
+
variant="primary"
|
|
443
|
+
icon="DownloadCloud"
|
|
444
|
+
loading={activeUpdate === "all"}
|
|
445
|
+
disabled={activeUpdate !== null}
|
|
446
|
+
onPress={() => runUpdateAll(false)}
|
|
447
|
+
/>
|
|
448
|
+
) : null}
|
|
449
|
+
</ScrollView>
|
|
450
|
+
</PluginThemeProvider>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function PluginUpdatesPopover(props: PluginButtonContentProps) {
|
|
455
|
+
return (
|
|
456
|
+
<PluginThemeProvider theme={props.theme} layout={props.layout}>
|
|
457
|
+
<PluginUpdatesPopoverInner {...props} />
|
|
458
|
+
</PluginThemeProvider>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function registerPluginUpdateHeaders(client: {
|
|
463
|
+
addHeaderButton(contribution: {
|
|
464
|
+
id: string;
|
|
465
|
+
workspaceId: string;
|
|
466
|
+
button: {
|
|
467
|
+
title: string;
|
|
468
|
+
icon: typeof PluginUpdatesIcon;
|
|
469
|
+
behavior: { kind: "popover"; Content: typeof PluginUpdatesPopover };
|
|
470
|
+
};
|
|
471
|
+
}): PluginButtonRegistration;
|
|
472
|
+
paseo: {
|
|
473
|
+
agents: {
|
|
474
|
+
list(): Promise<{
|
|
475
|
+
entries: Array<{ agent?: { workspaceId?: string | null } }>;
|
|
476
|
+
}>;
|
|
477
|
+
subscribe(listener: (update: { kind: string; agent?: { workspaceId?: string | null } }) => void): () => void;
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
}) {
|
|
481
|
+
const registrations = new Map<string, PluginButtonRegistration>();
|
|
482
|
+
const add = (workspaceId: string) => {
|
|
483
|
+
if (!workspaceId || registrations.has(workspaceId)) return;
|
|
484
|
+
registrations.set(
|
|
485
|
+
workspaceId,
|
|
486
|
+
client.addHeaderButton({
|
|
487
|
+
id: "plugin-updates",
|
|
488
|
+
workspaceId,
|
|
489
|
+
button: {
|
|
490
|
+
title: "Plugin updates",
|
|
491
|
+
icon: PluginUpdatesIcon,
|
|
492
|
+
behavior: { kind: "popover", Content: PluginUpdatesPopover },
|
|
493
|
+
},
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
};
|
|
497
|
+
const subscription = client.paseo.agents.subscribe((update) => {
|
|
498
|
+
if (update.kind === "upsert" && update.agent?.workspaceId) add(update.agent.workspaceId);
|
|
499
|
+
});
|
|
500
|
+
void client.paseo.agents.list().then((result) => {
|
|
501
|
+
for (const entry of result.entries) {
|
|
502
|
+
if (entry.agent?.workspaceId) add(entry.agent.workspaceId);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
return () => {
|
|
506
|
+
subscription();
|
|
507
|
+
for (const registration of registrations.values()) registration.remove();
|
|
508
|
+
registrations.clear();
|
|
509
|
+
};
|
|
510
|
+
}
|