Open files in external apps + code-review UX pass (#942)
Adds a server-side "open the current file in an external app" control to the code-review and annotate surfaces (split-button with host-detected apps; last-used becomes the default; cross-platform launch mirrored across the Bun and Pi runtimes), plus a code-review UX pass: file-header change letters and line counts, the diff-settings cog grouped with Split/Unified, an all-files collapse/expand-all toggle, and the semantic diff moved to a resizable sidebar accordion. Also fixes Cmd+click code navigation (worker token transformer). Hardened over three adversarial review rounds: shared, tested path-containment for open-in (Bun + Pi), annotate scoping aligned to /api/doc reference roots, strict PR-checkout rooting (never the launch repo), deduped app-catalog and semantic-diff fetches, macOS bundle-only availability, Windows launch fixes (reveal + terminal off cmd's parser), and PR-checkout tracking across switch and pool warmup.
@@ -0,0 +1,152 @@
|
||||
# Recap / Handoff — PR #942: Open files in external apps + code-review UX pass
|
||||
|
||||
- **PR:** https://github.com/backnotprop/plannotator/pull/942
|
||||
- **Branch:** `feat/openfile` (rebased onto `main` @ #936)
|
||||
- **Date:** 2026-06-18
|
||||
- **Scope:** 46 files, ~+2.3k / −0.4k. Pure frontend except two new server endpoints (mirrored Bun + Pi). No new diff backend.
|
||||
|
||||
This is a developer handoff. Read top-to-bottom to understand *why* each piece exists and *where* to look.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
One headline feature — **open the current file in an external app** — plus a batch of code-review UX improvements that came out of dogfooding, and **one real bug fix** (Cmd+click code navigation was silently dead). The semantic-diff view was also migrated from a dock panel into a sidebar accordion.
|
||||
|
||||
Commits (oldest → newest):
|
||||
|
||||
```
|
||||
677ad27f feat: open the current file in an external app (review + annotate)
|
||||
03821638 feat(review): file-header change letters and line counts
|
||||
2cea9f31 feat(review): group the diff-settings cog with the Split/Unified toggle
|
||||
384a4eff feat(review): collapse/expand-all toggle in the all-files view
|
||||
797e4eeb fix(review): restore Cmd+click code navigation
|
||||
9a957947 feat(review): semantic diff as a resizable sidebar accordion
|
||||
7f7bd6f1 docs(adr): backlog spike — git graph view exploration
|
||||
65f79376 fix(annotate): inline the open-in button beside the file name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Open file in an external app (the headline)
|
||||
|
||||
**What it does:** a split-button (app icon + chevron) in both the **review file header** and the **annotate file badge**. The left half opens the current file in the last-used app; the chevron drops down every detected app (Finder/Explorer reveal, VS Code, Cursor, Zed, Sublime, terminals, Xcode, etc.) plus Copy path / Copy file diff. Picking an app opens it **and** makes it the new default — there is **no settings UI**; the dropdown *is* the preference. Ported from OpenCode's "Open in" control.
|
||||
|
||||
**How it works (the important part): this is a Bun/browser app, not Electron.** Launching happens **server-side**:
|
||||
- `GET /api/open-in/apps` → `{ available, apps[] }`. `available=false` in remote/headless sessions (the UI hides the control). `apps` is the host-detected, launchable subset.
|
||||
- `POST /api/open-in` `{ filePath, base?, appId? }` → resolves an absolute path, **containment-checks it**, then launches.
|
||||
|
||||
Per-kind launch semantics (`packages/server/open-in.ts` `openFileInApp`):
|
||||
- **editor** → open the file (`open -a "App" file` / `<bin> file`)
|
||||
- **file-manager (reveal)** → reveal it (`open -R` / `explorer /select,` / `xdg-open <dir>`)
|
||||
- **terminal** → open the file's **parent directory**
|
||||
- **system default** → `open` / `start` / `xdg-open` with no app
|
||||
|
||||
All launches use **argv arrays, never shell strings** (injection-safe), modeled on `packages/server/browser.ts`.
|
||||
|
||||
**Key files:**
|
||||
- `packages/shared/open-in-apps.ts` — the single-source app catalog (id, label, kind, per-platform launch fields). Imported by servers + UI.
|
||||
- `packages/ui/components/OpenInAppButton.tsx` — the split-button + dropdown.
|
||||
- `packages/ui/components/icons/AppIcon.tsx` + `icons/app/*` — app brand icons, **base64-inlined as data-URIs** (see Gotchas: single-file HTML).
|
||||
- `packages/ui/utils/storage.ts` — `getLastOpenInApp` / `setLastOpenInApp` (cookie; ports are random per invocation).
|
||||
- `packages/server/open-in.ts` + endpoints in `packages/server/review.ts` & `annotate.ts`.
|
||||
- **Pi mirror:** `apps/pi-extension/server/open-in-apps.ts` + endpoints in `serverReview.ts` / `serverAnnotate.ts` (`vendor.sh` copies the shared catalog).
|
||||
- Review wiring: `FileHeader.tsx` (`showLabel={!isCompact}` → shows the app name when wide). Path is repo-relative → resolved server-side against the VCS root (`resolveAgentCwd`); `agentCwd` was added to `ReviewState`.
|
||||
- Annotate wiring: `DocBadges.tsx` + threaded through `Viewer.tsx` ← `editor/App.tsx` (`openInAppPath = linkedDocHook.isActive ? linkedDocHook.filepath : sourceFilePath`).
|
||||
|
||||
**Decisions worth knowing:**
|
||||
- **No "Default app" menu item** (it was tried, then removed) — last-used is the only default mechanism.
|
||||
- **Reveal label is just "Finder"** (per-platform), not "Reveal in Finder".
|
||||
- **Path safety:** review resolves against the VCS root server-side (not the client `base`, which is wrong when `plannotator review` runs from a subdir); annotate resolves an absolute path against its own dir. Both containment-checked.
|
||||
- **Remote/PR-without-local-checkout:** the control hides (no resolvable file on disk).
|
||||
|
||||
---
|
||||
|
||||
## 2. File-header change letters + line counts (review)
|
||||
|
||||
Replaced the old box status-icons with **`A`/`D`/`R` letters to the right of the path** + `+N −M` counts, matching the file tree. **Modified files stay bare** — the `+/−` already says it changed, so only add/delete/rename get a letter. Single file: `packages/review-editor/components/FileHeader.tsx` (`FileStatusLetter`, `countChanges`). Counts are derived from the patch in-component (no prop plumbing).
|
||||
|
||||
Also in this area: **Pierre's built-in single-file header is disabled** (`DiffViewer.tsx`, `disableFileHeader: true`) so we don't render two headers — we supply our own.
|
||||
|
||||
---
|
||||
|
||||
## 3. Settings cog grouped with the Split/Unified toggle (review)
|
||||
|
||||
The diff-display settings cog (`DiffOptionsPopover`) now lives **inside the same pill** as the Split/Unified toggle, divider-separated, with an open-state that mirrors the active-segment look. `packages/review-editor/App.tsx` (toolbar) + `DiffOptionsPopover.tsx`. (This commit also carries the small `agentCwd` memo plumbing for open-in.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Collapse / expand-all toggle in the all-files view (review)
|
||||
|
||||
An **embedded corner cell** at the top-left of the all-files panel that folds/unfolds every file at once (smart: collapses if any open, else expands). `packages/review-editor/components/AllFilesCodeView.tsx` — iterates the CodeView items, flips `item.collapsed`, `updateItem`. This commit also flips on `useTokenTransformer` for the all-files surface (see #5).
|
||||
|
||||
---
|
||||
|
||||
## 5. Fix: Cmd+click code navigation ⚠️ the non-obvious bug
|
||||
|
||||
**Symptom:** Cmd+hover/Cmd+click on a symbol did nothing — no underline cue, no References panel. Pre-existing on `main`, not caused by this branch.
|
||||
|
||||
**Root cause (took a headless-browser DOM inspection to find):** token-level interaction only works when Pierre wraps each token with a `data-char` attribute, which only happens when the **token transformer** runs. The renderer enables the transformer from `shouldUseTokenTransformer(options)` — but the per-component `onTokenClick`/`onTokenEnter` options are **dropped before they reach the worker**, and **highlighting runs in a web worker** whose render options come from a *separate* init config. So tokens highlighted (colors appeared) but never got `data-char` → `isTokenPointerTarget` never matched → handlers never fired. Silent no-op.
|
||||
|
||||
**Fix:** set `useTokenTransformer: true` on the **worker pool's** init options — `packages/review-editor/workerPool.tsx` — (plus the per-component option on `DiffViewer`/`AllFilesCodeView` as a main-thread fallback). Verified end-to-end with puppeteer: 0 → 1660 `data-char` tokens, hover cue + `/api/code-nav/resolve` both fire. **No Pierre upgrade needed** (the worker plumbing gap is the same in 1.2.8 and 1.2.10).
|
||||
|
||||
If token interactions ever break again, this is the first place to look.
|
||||
|
||||
---
|
||||
|
||||
## 6. Semantic diff → resizable sidebar accordion (review)
|
||||
|
||||
The semantic diff used to open a **dockview panel**. It's now a **collapsible accordion pinned to the bottom of the file tree** — same entity rows (`SemanticDiffRows` reused), same click-to-navigate (`openDiffFile` + line select), sized for the sidebar, with a **vertical drag handle to resize** it.
|
||||
|
||||
- New: `packages/review-editor/components/SemanticDiffAccordion.tsx` (self-contained via `ReviewStateContext`; fetches the existing `/api/semantic-diff`).
|
||||
- `useResizablePanel` was **generalized to a `y` axis** (was width-only) — `packages/ui/hooks/useResizablePanel.ts`. Backward-compatible (`axis` defaults to `'x'`, existing sidebar/panel resizers untouched).
|
||||
- **Removed** the dock panel + button + all wiring: deleted `ReviewSemanticDiffPanel.tsx`, removed `SEMANTIC_DIFF` from `reviewPanelTypes`/`reviewPanelComponents`, dropped `isSemanticDiffActive` + the load-state callbacks from `App.tsx` and `ReviewStateContext`. `semanticDiffAvailable` stays — it's the accordion's show/hide gate.
|
||||
- CSS: `.semantic-diff-accordion` scope in `review-editor/index.css` compacts the panel's rows to sidebar size.
|
||||
|
||||
The per-file `sem · N` badge in file headers is a separate, untouched feature.
|
||||
|
||||
---
|
||||
|
||||
## 7. Inline open-in in annotate (fix)
|
||||
|
||||
After the rebase, #936 had added a `folder-file` breadcrumb variant (Close + filename). The open-in button was rendering as a standalone row stacked *above* it. Now it renders **inline to the right** within each file row (source / linked-doc breadcrumb), with a standalone fallback only when there's no file row. `packages/ui/components/DocBadges.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 8. ADR backlog spike — git graph view (docs, not built)
|
||||
|
||||
`adr/research/SPIKE-git-graph-view-*.md`. Explored a commit-graph view (click a commit → its files load on the right). **Key finding:** the diff viewer is source-agnostic (consumes a `rawPatch`), so it's largely reusable; net-new work is a per-commit diff mode + the graph lane rendering; **git-only** caveat (jj/P4). **Backlogged**, two-phase plan if revisited.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting notes / gotchas
|
||||
|
||||
- **Single-file HTML build.** The review/plan apps ship as one self-contained HTML file. Large assets emitted as separate files break that, which is why all app icons are **base64-inlined** in `AppIcon.tsx` rather than imported as `.svg`/`.png` URLs.
|
||||
- **Two server runtimes.** Every server endpoint must exist in **both** the Bun server (`packages/server/`) and the **Pi** mirror (`apps/pi-extension/server/`), with the shared catalog vendored via `apps/pi-extension/vendor.sh`. The open-in endpoints are mirrored.
|
||||
- **Build order.** Review-editor UI changes require `bun run --cwd apps/review build` **before** `bun run build:hook` (the hook copies pre-built HTML). Run `bun run typecheck` for the cross-package check.
|
||||
- **Cookies, not localStorage** for persistence (open-in last-used, resize sizes) — each hook invocation runs on a random port.
|
||||
- **Rebase.** Rebased cleanly onto `main` (#936, "Persist saved annotate file edits in drafts"). Only two trivial list-append conflicts (`vendor.sh`, `shared/package.json`) — kept both sides. The heavy overlap (`editor/App.tsx`, `DocBadges`, `Viewer`) auto-merged correctly (verified the open-in wiring survived). NOTE: the badge-removal branch is **not on main yet**, so those changes are not in this PR.
|
||||
|
||||
## How it was verified
|
||||
|
||||
- `bun run typecheck` and the review→hook builds green throughout.
|
||||
- The open-in cross-platform launch, app detection, and both header placements were exercised live (`plannotator review` / `plannotator annotate .`).
|
||||
- The code-nav fix was confirmed with a headless Chrome (puppeteer) DOM inspection — token `data-char` presence + the actual `/api/code-nav/resolve` request firing on a simulated Cmd+click.
|
||||
|
||||
## File index (start here)
|
||||
|
||||
| Area | Files |
|
||||
|------|-------|
|
||||
| Open-in catalog/UI | `shared/open-in-apps.ts`, `ui/components/OpenInAppButton.tsx`, `ui/components/icons/AppIcon.tsx`, `ui/utils/storage.ts` |
|
||||
| Open-in server | `server/open-in.ts`, `server/review.ts`, `server/annotate.ts`, `apps/pi-extension/server/open-in-apps.ts` (+ serverReview/serverAnnotate) |
|
||||
| Review header/toolbar | `review-editor/components/FileHeader.tsx`, `review-editor/components/DiffOptionsPopover.tsx`, `review-editor/App.tsx` |
|
||||
| All-files / code-nav | `review-editor/components/AllFilesCodeView.tsx`, `review-editor/components/DiffViewer.tsx`, `review-editor/workerPool.tsx` |
|
||||
| Semantic accordion | `review-editor/components/SemanticDiffAccordion.tsx`, `ui/hooks/useResizablePanel.ts`, `review-editor/index.css` |
|
||||
| Annotate open-in | `ui/components/DocBadges.tsx`, `ui/components/Viewer.tsx`, `editor/App.tsx` |
|
||||
| Backlog | `adr/research/SPIKE-git-graph-view-*.md` |
|
||||
|
||||
## Follow-ups / backlog
|
||||
|
||||
- Re-rebase once the badge-removal branch lands on `main`.
|
||||
- Git-graph view (Phase 1: commit list + per-commit diff; Phase 2: graph rails) — see the spike.
|
||||
- Minor open-in polish deferred during build (e.g. unused `zed-dark` icon variant, a couple low-severity review notes) — see the open-in commit's history if needed.
|
||||
@@ -0,0 +1,64 @@
|
||||
# SPIKE: Git graph view in the code review app
|
||||
|
||||
Date: 2026-06-18
|
||||
Status: **Backlogged** — explored, not building now.
|
||||
|
||||
## What we explored
|
||||
|
||||
Idea: a git-graph view in the review app. Show a commit graph; click a commit and
|
||||
its file changes load on the right — basically a cross between the all-files view
|
||||
and a way to switch between different commits' diffs.
|
||||
|
||||
This is a research note, not a commitment. We're parking it.
|
||||
|
||||
## Key finding: the diff viewer is already source-agnostic
|
||||
|
||||
`App.tsx` fetches `/api/diff` → gets a `rawPatch` string → `parseDiffToFiles()` →
|
||||
feeds the FileTree + all-files view + single-file view. The viewer doesn't care
|
||||
where the patch came from. So "click a commit → see its files" = fetch that
|
||||
commit's patch and feed the same pipeline.
|
||||
|
||||
**The Pierre diff viewer needs ~no changes.** That was the main worry, and it's
|
||||
the part that's basically free.
|
||||
|
||||
## What's already reusable
|
||||
|
||||
- `runGitDiff` (packages/shared/review-core.ts) already does diff modes:
|
||||
uncommitted / staged / last-commit / merge-base / all / branch. `last-commit`
|
||||
is literally `git diff HEAD~1..HEAD` — so a single commit's diff
|
||||
(`git diff <sha>^..<sha>`) is a tiny new mode.
|
||||
- `listRecentCommits()` (review-core.ts ~245) already runs one `git log --pretty`
|
||||
and returns `RecentCommit[]` (powers the base-branch picker). That's the seed
|
||||
for a commit list.
|
||||
- `/api/diff/switch` already swaps the active diff and re-renders. Selecting a
|
||||
commit is conceptually another switch.
|
||||
|
||||
## What's net-new
|
||||
|
||||
1. **Per-commit diff** — extend the diff backend to take a sha
|
||||
(`git show` / `git diff <sha>^..<sha>`); context-expansion base becomes the
|
||||
parent. Small. Must be mirrored in the Pi server.
|
||||
2. **Graph data** — add parent hashes (`%P`) + refs (`%D`) to the log, then
|
||||
compute lane layout (which column each dot/edge sits in). The DAG parse is
|
||||
easy; the lane/rail layout is the fiddly part.
|
||||
3. **Graph UI** — rows + SVG rails (dots, colored branch lines), commit metadata,
|
||||
selection, virtualization for big repos. This is the bulk of the work.
|
||||
|
||||
## The real gotcha
|
||||
|
||||
The diff system also supports **jj and Perforce**, not just git. A commit graph
|
||||
is git-specific (jj has its own log/op-log model). So this would be **git-only**
|
||||
unless we later add a jj variant. Decide up front.
|
||||
|
||||
## Suggested phasing (if/when we pick this up)
|
||||
|
||||
- **Phase 1 — commit browser (small/medium):** flat commit *list* in the sidebar
|
||||
+ per-commit diff mode + click loads into the existing all-files view. No viewer
|
||||
changes, modest backend add. Validates the workflow cheaply.
|
||||
- **Phase 2 — the graph (medium, mostly frontend):** layer lane rails / branch
|
||||
colors on top of Phase 1's data + wiring. The "cool visualization" lives here.
|
||||
|
||||
## Takeaway
|
||||
|
||||
Very doable. Phase 1 is a surprisingly small lift because the viewer is decoupled.
|
||||
Backlogged for now — revisit when there's appetite, start with Phase 1.
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Open-in-App launcher — Node.js equivalent of packages/server/open-in-apps.ts.
|
||||
*
|
||||
* Launches a file in a chosen desktop app (editor / file manager / terminal)
|
||||
* using node:child_process execFile with argv arrays (never shell strings) to
|
||||
* avoid command injection. The app catalog is the runtime-agnostic single
|
||||
* source of truth shared with the Bun server and the UI
|
||||
* (packages/shared/open-in-apps.ts, vendored into generated/).
|
||||
*/
|
||||
|
||||
import { execFile, execFileSync, spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import {
|
||||
OPEN_IN_APPS,
|
||||
getOpenInApp,
|
||||
resolveRevealIcon,
|
||||
resolveRevealLabel,
|
||||
type OpenInApp,
|
||||
type OpenInKind,
|
||||
type OpenInPlatform,
|
||||
} from "../generated/open-in-apps.js";
|
||||
|
||||
function currentPlatform(): OpenInPlatform {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return "win";
|
||||
case "darwin":
|
||||
return "mac";
|
||||
default:
|
||||
return "linux";
|
||||
}
|
||||
}
|
||||
|
||||
/** which()-equivalent: returns true if a binary resolves on PATH. */
|
||||
function whichBin(bin: string): boolean {
|
||||
try {
|
||||
const finder = process.platform === "win32" ? "where" : "which";
|
||||
execFileSync(finder, [bin], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** macOS: does an application bundle exist in any standard Applications dir? */
|
||||
function macAppExists(appName: string): boolean {
|
||||
const candidates = [
|
||||
`/Applications/${appName}.app`,
|
||||
join(os.homedir(), "Applications", `${appName}.app`),
|
||||
`/System/Applications/${appName}.app`,
|
||||
// Terminal.app and other built-ins live in the Utilities subfolder.
|
||||
`/System/Applications/Utilities/${appName}.app`,
|
||||
];
|
||||
return candidates.some((p) => existsSync(p));
|
||||
}
|
||||
|
||||
/** Is the given catalog app launchable on THIS host? */
|
||||
function isAppAvailable(app: OpenInApp): boolean {
|
||||
// reveal is always available.
|
||||
if (app.id === "reveal") return true;
|
||||
|
||||
const platform = currentPlatform();
|
||||
if (platform === "mac") {
|
||||
// We launch via `open -a "<appName>"`, so availability must mean the .app
|
||||
// bundle exists — matching the Bun runtime.
|
||||
return !!app.mac?.appName && macAppExists(app.mac.appName);
|
||||
}
|
||||
if (platform === "win") {
|
||||
return app.win?.bin ? whichBin(app.win.bin) : false;
|
||||
}
|
||||
// linux
|
||||
return app.linux?.bin ? whichBin(app.linux.bin) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog filtered to apps launchable on this host (always including
|
||||
* `reveal`), in catalog order, with `reveal`'s label and
|
||||
* icon resolved per host platform. Used by GET /api/open-in/apps.
|
||||
*/
|
||||
export function getAvailableOpenInApps(): Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: OpenInKind;
|
||||
icon: string;
|
||||
}> {
|
||||
const platform = currentPlatform();
|
||||
return OPEN_IN_APPS.filter(isAppAvailable).map((app) => {
|
||||
if (app.id === "reveal") {
|
||||
return {
|
||||
id: app.id,
|
||||
label: resolveRevealLabel(platform),
|
||||
kind: app.kind,
|
||||
icon: resolveRevealIcon(platform),
|
||||
};
|
||||
}
|
||||
return { id: app.id, label: app.label, kind: app.kind, icon: app.icon };
|
||||
});
|
||||
}
|
||||
|
||||
/** Run execFile and surface ENOENT (app not found) as a friendly error. */
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
notFoundLabel: string,
|
||||
opts?: { cwd?: string },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = execFile(cmd, args, opts?.cwd ? { cwd: opts.cwd } : {}, (err) => {
|
||||
if (!err) {
|
||||
resolve({ ok: true });
|
||||
return;
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || /ENOENT|not found/i.test(err.message)) {
|
||||
resolve({ ok: false, error: `${notFoundLabel} was not found on this system.` });
|
||||
} else {
|
||||
resolve({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
// Detach: we don't care about the child's lifetime once launched.
|
||||
proc.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a launcher we can't meaningfully await — e.g. Windows `explorer`, which
|
||||
* exits non-zero even on success. Returns ok immediately; an async spawn failure
|
||||
* (missing binary) is swallowed so it can't crash the server. Mirrors the Bun
|
||||
* runtime's spawnDetached (packages/server/open-in.ts).
|
||||
*/
|
||||
function spawnDetached(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.on("error", () => {}); // fire-and-forget; ignore async spawn failure
|
||||
child.unref();
|
||||
return Promise.resolve({ ok: true });
|
||||
} catch (err) {
|
||||
return Promise.resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch `absPath` in the app identified by `appId` (defaults / unknown ->
|
||||
* the OS default handler). Mirrors the Bun-side launch semantics exactly.
|
||||
*/
|
||||
export function openFileInApp(
|
||||
absPath: string,
|
||||
appId?: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const platform = currentPlatform();
|
||||
const app = appId ? getOpenInApp(appId) : undefined;
|
||||
|
||||
// Unknown or undefined appId -> OS default handler on the file.
|
||||
if (!app) {
|
||||
if (platform === "mac") return run("open", [absPath], "Default app");
|
||||
if (platform === "win")
|
||||
return run("cmd", ["/c", "start", "", basename(absPath)], "Default app", { cwd: dirname(absPath) });
|
||||
return run("xdg-open", [absPath], "Default app");
|
||||
}
|
||||
|
||||
if (app.kind === "file-manager") {
|
||||
// Reveal the file in the OS file manager.
|
||||
if (platform === "mac") return run("open", ["-R", absPath], "Finder");
|
||||
// explorer.exe exits non-zero even on success; launch fire-and-forget so
|
||||
// a successful reveal doesn't report failure.
|
||||
if (platform === "win") return spawnDetached("explorer", [`/select,${absPath}`]);
|
||||
return run("xdg-open", [dirname(absPath)], "File manager");
|
||||
}
|
||||
|
||||
if (app.kind === "terminal") {
|
||||
// Terminals open the file's parent directory.
|
||||
const dir = dirname(absPath);
|
||||
if (platform === "mac") {
|
||||
if (!app.mac?.appName)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
return run("open", ["-a", app.mac.appName, dir], app.label);
|
||||
}
|
||||
if (platform === "win") {
|
||||
if (!app.win?.bin)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
// Open a new console window for the terminal. The directory is passed
|
||||
// via cwd (NOT a cmd argument) so a repo-controlled path never reaches
|
||||
// cmd's parser; `start` inherits that cwd. bin is a trusted catalog value.
|
||||
return run("cmd", ["/c", "start", "", app.win.bin], app.label, { cwd: dir });
|
||||
}
|
||||
if (!app.linux?.bin)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
return run(app.linux.bin, [dir], app.label);
|
||||
}
|
||||
|
||||
// editor -> open the file itself.
|
||||
if (platform === "mac") {
|
||||
if (!app.mac?.appName)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
return run("open", ["-a", app.mac.appName, absPath], app.label);
|
||||
}
|
||||
if (platform === "win") {
|
||||
if (!app.win?.bin)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
return run(app.win.bin, [absPath], app.label);
|
||||
}
|
||||
if (!app.linux?.bin)
|
||||
return Promise.resolve({ ok: false, error: `${app.label} is not available on this platform.` });
|
||||
return run(app.linux.bin, [absPath], app.label);
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
import { html, json, parseBody, requestUrl } from "./helpers.js";
|
||||
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";
|
||||
|
||||
import { listenOnPort } from "./network.js";
|
||||
import { isRemoteSession, listenOnPort } from "./network.js";
|
||||
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.js";
|
||||
|
||||
import { getRepoInfo } from "./project.js";
|
||||
import {
|
||||
@@ -47,7 +48,7 @@ import {
|
||||
normalizeHtmlAssetRoutePath,
|
||||
rewriteHtmlAssetReferences,
|
||||
} from "../generated/html-assets.js";
|
||||
import { inlineHtmlLocalAssets, isWithinDirectory, MAX_HTML_ASSET_BYTES } from "../generated/html-assets-node.js";
|
||||
import { inlineHtmlLocalAssets, isWithinDirectory, MAX_HTML_ASSET_BYTES, resolveOpenInTarget } from "../generated/html-assets-node.js";
|
||||
|
||||
export interface AnnotateServerResult {
|
||||
port: number;
|
||||
@@ -369,6 +370,45 @@ export async function startAnnotateServer(options: {
|
||||
return;
|
||||
} else if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
await handleUploadRequest(req, res);
|
||||
} else if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
||||
// Remote/headless sessions can't open apps on the user's machine, and
|
||||
// URL annotations have no local file to reveal — report unavailable so
|
||||
// the UI hides the control entirely.
|
||||
const urlSource = /^https?:\/\//i.test(options.filePath);
|
||||
if (isRemoteSession() || urlSource) {
|
||||
json(res, { available: false, apps: [] });
|
||||
return;
|
||||
}
|
||||
json(res, { available: true, apps: getAvailableOpenInApps() });
|
||||
} else if (url.pathname === "/api/open-in" && req.method === "POST") {
|
||||
if (isRemoteSession() || /^https?:\/\//i.test(options.filePath)) {
|
||||
json(res, { ok: false, error: "Open in app is unavailable for this source" }, 400);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
const filePath = body.filePath;
|
||||
if (typeof filePath !== "string" || !filePath) {
|
||||
json(res, { ok: false, error: "Missing filePath" }, 400);
|
||||
return;
|
||||
}
|
||||
const appId = typeof body.appId === "string" ? body.appId : undefined;
|
||||
// Confine opens to the same reference roots /api/doc serves from,
|
||||
// so any linked doc the user can view can also be opened.
|
||||
const abs = resolveOpenInTarget(filePath, null, getReferenceRootPaths);
|
||||
if (abs == null) {
|
||||
json(res, { ok: false, error: "Path is outside the allowed directory" }, 403);
|
||||
return;
|
||||
}
|
||||
const result = await openFileInApp(abs, appId);
|
||||
json(res, result, 200);
|
||||
} catch (err) {
|
||||
json(
|
||||
res,
|
||||
{ ok: false, error: err instanceof Error ? err.message : "Failed to open file" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
} else if (url.pathname === "/api/draft") {
|
||||
await handleDraftRequest(req, res, draftKey);
|
||||
} else if (url.pathname === "/api/doc" && req.method === "GET") {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import { basename } from "node:path";
|
||||
import { basename, resolve as resolvePath } from "node:path";
|
||||
|
||||
import { contentHash, deleteDraft } from "../generated/draft.js";
|
||||
import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveSharingEnabled } from "../generated/config.js";
|
||||
@@ -58,6 +58,8 @@ import { html, json, parseBody, requestUrl } from "./helpers.js";
|
||||
import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.js";
|
||||
|
||||
import { isRemoteSession, listenOnPort } from "./network.js";
|
||||
import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.js";
|
||||
import { resolveOpenInTarget } from "../generated/html-assets-node.js";
|
||||
import {
|
||||
fetchPR,
|
||||
fetchPRContext,
|
||||
@@ -386,6 +388,29 @@ export async function startReviewServer(options: {
|
||||
if (options.agentCwd) return options.agentCwd;
|
||||
return resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd) ?? process.cwd();
|
||||
}
|
||||
// The current PR's local checkout if one is usable, else null. Mirrors the
|
||||
// Bun review server's resolvePRLocalCwd: a pool entry that exists but isn't
|
||||
// ready yet yields null (no usable checkout), distinct from resolveAgentCwd
|
||||
// which always falls back to a cwd for agent/launch resolution. Used to
|
||||
// advertise the Open-in root to the client without a page reload.
|
||||
function resolvePRLocalCwd(): string | null {
|
||||
const pool = options.worktreePool;
|
||||
if (pool && prMeta) {
|
||||
const entry = pool.get(prMeta.url);
|
||||
if (entry?.ready) return entry.path;
|
||||
if (entry) return null;
|
||||
}
|
||||
return options.agentCwd && existsSync(options.agentCwd) ? options.agentCwd : null;
|
||||
}
|
||||
// Strict launch root for /api/open-in: in PR pool mode only the PR's own
|
||||
// checkout is acceptable — never the launch-repo fallback resolveAgentCwd
|
||||
// uses. Returns [] until ready so resolveOpenInTarget rejects (the button is
|
||||
// gated off then anyway); non-PR resolves to the working tree as usual.
|
||||
function resolveOpenInRoot(): string | string[] {
|
||||
if (workspace) return workspace.root;
|
||||
if (options.worktreePool && prMeta) return resolvePRLocalCwd() ?? [];
|
||||
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, options.gitContext?.cwd) ?? process.cwd();
|
||||
}
|
||||
function getWorkspacePromptContext(): WorkspaceReviewPromptContext | undefined {
|
||||
if (!workspace) return undefined;
|
||||
return workspace.getPromptContext();
|
||||
@@ -696,8 +721,16 @@ export async function startReviewServer(options: {
|
||||
pasteApiUrl,
|
||||
repoInfo,
|
||||
isWSL: wslFlag,
|
||||
...(options.agentCwd && { agentCwd: options.agentCwd }),
|
||||
...(workspace && { agentCwd: workspace.root }),
|
||||
// PR mode advertises the ready PR checkout (null while warming), so
|
||||
// the Open-in button gates correctly from the initial load — not the
|
||||
// launch repo. Non-PR keeps the workspace/local cwd.
|
||||
...(isPRMode
|
||||
? { agentCwd: resolvePRLocalCwd() }
|
||||
: workspace
|
||||
? { agentCwd: workspace.root }
|
||||
: options.agentCwd
|
||||
? { agentCwd: options.agentCwd }
|
||||
: {}),
|
||||
...(isPRMode && {
|
||||
prMetadata: prMeta,
|
||||
platformUser,
|
||||
@@ -716,9 +749,15 @@ export async function startReviewServer(options: {
|
||||
// Cheap staleness probe — has the underlying VCS state changed since
|
||||
// the current diff snapshot was computed? Best-effort: anything that
|
||||
// cannot be fingerprinted reports fresh (no banner).
|
||||
// In PR review the local checkout can appear (pool warmup) or change
|
||||
// (in-place PR switch) after the initial /api/diff, so re-advertise it
|
||||
// on every probe — the Open-in control tracks the current checkout
|
||||
// without a page reload. Null until a usable checkout exists (the pool
|
||||
// resolves a path only once ready). Non-PR sessions omit this field.
|
||||
const prCwdAdvert = isPRMode ? { agentCwd: resolvePRLocalCwd() } : {};
|
||||
const baseline = currentFingerprint;
|
||||
if (baseline == null) {
|
||||
json(res, { fresh: true });
|
||||
json(res, { fresh: true, ...prCwdAdvert });
|
||||
return;
|
||||
}
|
||||
const probe = await computeDiffFingerprint();
|
||||
@@ -726,13 +765,13 @@ export async function startReviewServer(options: {
|
||||
// fingerprint); report fresh and let the next poll compare against
|
||||
// the new baseline.
|
||||
if (currentFingerprint !== baseline) {
|
||||
json(res, { fresh: true });
|
||||
json(res, { fresh: true, ...prCwdAdvert });
|
||||
return;
|
||||
}
|
||||
const fresh = probe == null || probe === baseline;
|
||||
// The probe fingerprint lets the client distinguish "still the same
|
||||
// staleness I dismissed" from "ANOTHER change landed since".
|
||||
json(res, { fresh, ...(fresh ? {} : { fingerprint: probe }) });
|
||||
json(res, { fresh, ...(fresh ? {} : { fingerprint: probe }), ...prCwdAdvert });
|
||||
} else if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
|
||||
json(res, await getSemanticDiff(url));
|
||||
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
||||
@@ -1013,6 +1052,9 @@ export async function startReviewServer(options: {
|
||||
rawPatch: currentPatch,
|
||||
gitRef: currentGitRef,
|
||||
prMetadata: pr.metadata,
|
||||
// The new PR's checkout (null while warming) so Open-in re-roots
|
||||
// immediately on switch instead of waiting for the 5s probe.
|
||||
agentCwd: resolvePRLocalCwd() ?? null,
|
||||
prStackInfo,
|
||||
prStackTree,
|
||||
prDiffScope: currentPRDiffScope,
|
||||
@@ -1346,6 +1388,46 @@ export async function startReviewServer(options: {
|
||||
err instanceof Error ? err.message : "Failed to stage file";
|
||||
json(res, { error: message }, 500);
|
||||
}
|
||||
} else if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
||||
// Remote/headless sessions can't open apps on the user's machine —
|
||||
// report unavailable so the UI hides the control entirely.
|
||||
if (isRemote) {
|
||||
json(res, { available: false, apps: [] });
|
||||
return;
|
||||
}
|
||||
json(res, { available: true, apps: getAvailableOpenInApps() });
|
||||
} else if (url.pathname === "/api/open-in" && req.method === "POST") {
|
||||
if (isRemote) {
|
||||
json(res, { ok: false, error: "Open in app is unavailable in remote sessions" }, 400);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
const filePath = body.filePath;
|
||||
if (typeof filePath !== "string" || !filePath) {
|
||||
json(res, { ok: false, error: "Missing filePath" }, 400);
|
||||
return;
|
||||
}
|
||||
const appId = typeof body.appId === "string" ? body.appId : undefined;
|
||||
// Resolve repo-relative `git diff` paths against the VCS root
|
||||
// server-side (resolveAgentCwd folds in workspace.root, the PR
|
||||
// local checkout, resolveVcsCwd(gitContext.cwd), and process.cwd())
|
||||
// — not the client `base`, which is wrong when review runs from a
|
||||
// subdirectory — then containment-check.
|
||||
const abs = resolveOpenInTarget(filePath, null, resolveOpenInRoot);
|
||||
if (abs == null) {
|
||||
json(res, { ok: false, error: "Path is outside the allowed directory" }, 403);
|
||||
return;
|
||||
}
|
||||
const result = await openFileInApp(abs, appId);
|
||||
json(res, result, 200);
|
||||
} catch (err) {
|
||||
json(
|
||||
res,
|
||||
{ ok: false, error: err instanceof Error ? err.message : "Failed to open file" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
} else if (url.pathname === "/api/draft") {
|
||||
await handleDraftRequest(req, res, draftKey);
|
||||
} else if (url.pathname === "/favicon.svg") {
|
||||
|
||||
@@ -7,7 +7,7 @@ cd "$(dirname "$0")"
|
||||
rm -rf generated
|
||||
mkdir -p generated generated/ai/providers
|
||||
|
||||
for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status; do
|
||||
for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps; do
|
||||
src="../../packages/shared/$f.ts"
|
||||
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
|
||||
done
|
||||
|
||||
@@ -3983,6 +3983,7 @@ const App: React.FC = () => {
|
||||
copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined}
|
||||
archiveInfo={archive.currentInfo}
|
||||
sourceInfo={sourceInfo}
|
||||
openInAppPath={annotateMode ? (linkedDocHook.isActive ? (linkedDocHook.filepath ?? null) : sourceFilePath) : null}
|
||||
messagePickerInfo={
|
||||
annotateSource === 'message' && recentMessages.length > 1
|
||||
? {
|
||||
|
||||
@@ -81,7 +81,6 @@ import {
|
||||
REVIEW_PR_SUMMARY_PANEL_ID,
|
||||
REVIEW_PR_COMMENTS_PANEL_ID,
|
||||
REVIEW_PR_CHECKS_PANEL_ID,
|
||||
REVIEW_SEMANTIC_DIFF_PANEL_ID,
|
||||
REVIEW_ALL_FILES_PANEL_ID,
|
||||
REVIEW_CODE_NAV_PANEL_ID,
|
||||
} from './dock/reviewPanelTypes';
|
||||
@@ -127,7 +126,6 @@ const ReviewApp: React.FC = () => {
|
||||
// at call time instead of a stale closure capture.
|
||||
const isAllFilesActiveRef = useRef(isAllFilesActive);
|
||||
isAllFilesActiveRef.current = isAllFilesActive;
|
||||
const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false);
|
||||
const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false);
|
||||
const [isDiffPanelActive, setIsDiffPanelActive] = useState(false);
|
||||
const [allFilesVisibleFile, setAllFilesVisibleFile] = useState<string | null>(null);
|
||||
@@ -294,7 +292,6 @@ const ReviewApp: React.FC = () => {
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
const needsInitialDiffPanel = useRef(true);
|
||||
const semanticDiffAutoFallbackPending = useRef(false);
|
||||
|
||||
// PR context (lifted from sidebar so center dock PR panels can access it)
|
||||
const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null);
|
||||
@@ -304,7 +301,6 @@ const ReviewApp: React.FC = () => {
|
||||
const openDiffFile = useCallback((filePath: string) => {
|
||||
const file = files.find(candidate => candidate.path === filePath);
|
||||
if (!file) return;
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
|
||||
if (!dockApi) {
|
||||
const fileIndex = files.findIndex(candidate => candidate.path === filePath);
|
||||
@@ -486,9 +482,7 @@ const ReviewApp: React.FC = () => {
|
||||
existing.api.setTitle(`References: ${request.symbol}`);
|
||||
existing.api.setActive();
|
||||
} else {
|
||||
const refPanel = isSemanticDiffActive
|
||||
? REVIEW_SEMANTIC_DIFF_PANEL_ID
|
||||
: isAllFilesActive
|
||||
const refPanel = isAllFilesActive
|
||||
? REVIEW_ALL_FILES_PANEL_ID
|
||||
: REVIEW_DIFF_PANEL_ID;
|
||||
dockApi.addPanel({
|
||||
@@ -499,7 +493,7 @@ const ReviewApp: React.FC = () => {
|
||||
initialHeight: 250,
|
||||
});
|
||||
}
|
||||
}, [codeNav.resolve, dockApi, isAllFilesActive, isSemanticDiffActive, gitContext, agentCwd]);
|
||||
}, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]);
|
||||
|
||||
// Check AI capabilities on mount
|
||||
useEffect(() => {
|
||||
@@ -659,12 +653,10 @@ const ReviewApp: React.FC = () => {
|
||||
event.api.onDidActivePanelChange((panel) => {
|
||||
if (!panel) {
|
||||
setIsAllFilesActive(false);
|
||||
setIsSemanticDiffActive(false);
|
||||
setIsDiffPanelActive(false);
|
||||
return;
|
||||
}
|
||||
setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID);
|
||||
setIsSemanticDiffActive(panel.id === REVIEW_SEMANTIC_DIFF_PANEL_ID);
|
||||
setIsDiffPanelActive(isReviewDiffPanelId(panel.id));
|
||||
if (!isReviewDiffPanelId(panel.id)) return;
|
||||
const filePath = getReviewDiffPanelFilePath(panel.params);
|
||||
@@ -685,7 +677,6 @@ const ReviewApp: React.FC = () => {
|
||||
: undefined;
|
||||
const hideHeaders =
|
||||
lonePanel?.id === REVIEW_DIFF_PANEL_ID ||
|
||||
lonePanel?.id === REVIEW_SEMANTIC_DIFF_PANEL_ID ||
|
||||
lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID;
|
||||
for (const group of event.api.groups) {
|
||||
group.header.hidden = hideHeaders;
|
||||
@@ -775,7 +766,6 @@ const ReviewApp: React.FC = () => {
|
||||
|
||||
const openAllFilesPanel = useCallback(() => {
|
||||
if (!dockApi) return;
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID);
|
||||
if (existing) { existing.api.setActive(); return; }
|
||||
dockApi.addPanel({
|
||||
@@ -785,56 +775,10 @@ const ReviewApp: React.FC = () => {
|
||||
});
|
||||
}, [dockApi]);
|
||||
|
||||
const openSemanticDiffPanel = useCallback((options?: { autoFallbackOnError?: boolean }) => {
|
||||
if (!dockApi) return;
|
||||
semanticDiffAutoFallbackPending.current = options?.autoFallbackOnError === true;
|
||||
if (!semanticDiffAvailable) {
|
||||
openAllFilesPanel();
|
||||
return;
|
||||
}
|
||||
const existing = dockApi.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID);
|
||||
if (existing) { existing.api.setActive(); return; }
|
||||
dockApi.addPanel({
|
||||
id: REVIEW_SEMANTIC_DIFF_PANEL_ID,
|
||||
component: REVIEW_PANEL_TYPES.SEMANTIC_DIFF,
|
||||
title: 'Semantic diff',
|
||||
});
|
||||
}, [dockApi, openAllFilesPanel, semanticDiffAvailable]);
|
||||
|
||||
const handleSemanticDiffUnavailable = useCallback(() => {
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
setSemanticDiffAvailable(false);
|
||||
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
|
||||
openAllFilesPanel();
|
||||
}, [dockApi, openAllFilesPanel]);
|
||||
|
||||
const handleSemanticDiffLoadSuccess = useCallback(() => {
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
}, []);
|
||||
|
||||
const handleSemanticDiffLoadError = useCallback(() => {
|
||||
if (!semanticDiffAutoFallbackPending.current) return false;
|
||||
if (dockApi?.activePanel?.id !== REVIEW_SEMANTIC_DIFF_PANEL_ID) {
|
||||
// The user has already moved on; don't steal focus by auto-opening All files.
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
return false;
|
||||
}
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
|
||||
openAllFilesPanel();
|
||||
return true;
|
||||
}, [dockApi, openAllFilesPanel]);
|
||||
|
||||
const applySemanticDiffAdvert = useCallback((semanticDiff?: SemanticDiffAdvert) => {
|
||||
if (!semanticDiff) return;
|
||||
const available = semanticDiff.available === true;
|
||||
setSemanticDiffAvailable(available);
|
||||
if (!available) {
|
||||
semanticDiffAutoFallbackPending.current = false;
|
||||
dockApi?.getPanel(REVIEW_SEMANTIC_DIFF_PANEL_ID)?.api.close();
|
||||
if (isSemanticDiffActive) openAllFilesPanel();
|
||||
}
|
||||
}, [dockApi, isSemanticDiffActive, openAllFilesPanel]);
|
||||
setSemanticDiffAvailable(semanticDiff.available === true);
|
||||
}, []);
|
||||
|
||||
// Open the All files overview on first load. Semantic diff stays available via
|
||||
// the file-tree nav entry, but it's no longer the default landing view.
|
||||
@@ -915,7 +859,7 @@ const ReviewApp: React.FC = () => {
|
||||
base?: string;
|
||||
gitContext?: GitContext;
|
||||
diffOptions?: DiffOption[];
|
||||
agentCwd?: string;
|
||||
agentCwd?: string | null;
|
||||
sharingEnabled?: boolean;
|
||||
repoInfo?: { display: string; branch?: string };
|
||||
prMetadata?: PRMetadata;
|
||||
@@ -961,7 +905,7 @@ const ReviewApp: React.FC = () => {
|
||||
setSelectedBase(initial);
|
||||
setCommittedBase(initial);
|
||||
}
|
||||
if (data.agentCwd) setAgentCwd(data.agentCwd);
|
||||
if (data.agentCwd !== undefined) setAgentCwd(data.agentCwd);
|
||||
if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled);
|
||||
if (data.repoInfo) setRepoInfo(data.repoInfo);
|
||||
updatePRSession({
|
||||
@@ -1262,6 +1206,7 @@ const ReviewApp: React.FC = () => {
|
||||
repoInfo?: { display: string; branch?: string };
|
||||
viewedFiles?: string[]; error?: string;
|
||||
semanticDiff?: SemanticDiffAdvert;
|
||||
agentCwd?: string | null;
|
||||
}) {
|
||||
const isPRSwitch = !!data.prMetadata;
|
||||
const nextFiles = parseDiffToFiles(data.rawPatch);
|
||||
@@ -1294,6 +1239,15 @@ const ReviewApp: React.FC = () => {
|
||||
}
|
||||
setDiffError(data.error || null);
|
||||
applySemanticDiffAdvert(data.semanticDiff);
|
||||
// The PR's local checkout changes on switch (and warms in later). Use the
|
||||
// server's value when present; otherwise clear it on a switch so the Open-in
|
||||
// button can't keep pointing at the previous PR's checkout (the 5s freshness
|
||||
// probe re-advertises the new one). Scope toggles keep the same checkout.
|
||||
if (data.agentCwd !== undefined) {
|
||||
setAgentCwd(data.agentCwd);
|
||||
} else if (isPRSwitch) {
|
||||
setAgentCwd(null);
|
||||
}
|
||||
resetStagedFiles();
|
||||
}
|
||||
|
||||
@@ -1481,6 +1435,7 @@ const ReviewApp: React.FC = () => {
|
||||
const diffFreshness = useDiffFreshness({
|
||||
enabled: !!origin,
|
||||
resetKey: diffData?.rawPatch ?? '',
|
||||
onAgentCwd: setAgentCwd,
|
||||
});
|
||||
|
||||
const handleRefreshStaleDiff = useCallback(() => {
|
||||
@@ -1581,6 +1536,7 @@ const ReviewApp: React.FC = () => {
|
||||
feedbackDiffContext,
|
||||
prReviewScope: prReviewScopeLabel,
|
||||
prDiffScope,
|
||||
agentCwd,
|
||||
allAnnotations,
|
||||
externalAnnotations,
|
||||
selectedAnnotationId,
|
||||
@@ -1627,11 +1583,7 @@ const ReviewApp: React.FC = () => {
|
||||
openDiffFile,
|
||||
onAllFilesVisibleFileChange: setAllFilesVisibleFile,
|
||||
isAllFilesActive,
|
||||
isSemanticDiffActive,
|
||||
semanticDiffAvailable,
|
||||
onSemanticDiffUnavailable: handleSemanticDiffUnavailable,
|
||||
onSemanticDiffLoadError: handleSemanticDiffLoadError,
|
||||
onSemanticDiffLoadSuccess: handleSemanticDiffLoadSuccess,
|
||||
openTourPanel: handleOpenTour,
|
||||
onCodeNavRequest: handleCodeNavRequest,
|
||||
codeNavResult: codeNav.result,
|
||||
@@ -1640,7 +1592,7 @@ const ReviewApp: React.FC = () => {
|
||||
}), [
|
||||
files, diffData?.rawPatch, activeFileIndex, diffStyle, diffOverflow, diffIndicators,
|
||||
diffLineDiffType, diffShowLineNumbers, diffShowBackground,
|
||||
diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope,
|
||||
diffExpandUnchanged, diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, agentCwd,
|
||||
allAnnotations, externalAnnotations,
|
||||
selectedAnnotationId, pendingSelection, handleLineSelection,
|
||||
handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation,
|
||||
@@ -1652,8 +1604,7 @@ const ReviewApp: React.FC = () => {
|
||||
handleAskAI, handleAskAIForFile, handleViewAIResponse, handleClickAIMarker,
|
||||
aiHistoryForSelection, getAIHistoryForFile, agentJobs.jobs, prMetadata, prContext,
|
||||
isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile,
|
||||
handleOpenTour, isAllFilesActive, isSemanticDiffActive, semanticDiffAvailable,
|
||||
handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile,
|
||||
handleOpenTour, isAllFilesActive, semanticDiffAvailable, handleAddAnnotationForFile,
|
||||
handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol,
|
||||
]);
|
||||
|
||||
@@ -1995,7 +1946,7 @@ const ReviewApp: React.FC = () => {
|
||||
<div className="h-screen flex flex-col bg-background overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="py-1 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl z-50">
|
||||
<div className="min-w-0 flex items-center gap-2 md:gap-3 -ml-1.5 md:-ml-3">
|
||||
<div className="min-w-0 flex items-center gap-2 md:gap-3">
|
||||
{shouldShowFileTree && (
|
||||
<>
|
||||
<button
|
||||
@@ -2007,7 +1958,7 @@ const ReviewApp: React.FC = () => {
|
||||
}`}
|
||||
title={isFileTreeOpen ? 'Hide file tree' : 'Show file tree'}
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
<FolderTree className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
|
||||
</>
|
||||
@@ -2074,7 +2025,9 @@ const ReviewApp: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{/* Diff style toggle */}
|
||||
{/* Diff-display controls: Split/Unified toggle + settings cog in
|
||||
one pill. The cog is an action (not a toggle segment), set off
|
||||
by a divider so the grouping reads clearly. */}
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => handleDiffStyleChange('split')}
|
||||
@@ -2096,6 +2049,8 @@ const ReviewApp: React.FC = () => {
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border/60 mx-0.5" />
|
||||
<DiffOptionsPopover />
|
||||
</div>
|
||||
|
||||
{origin ? (
|
||||
@@ -2334,11 +2289,6 @@ const ReviewApp: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Global diff display options. These settings apply to every
|
||||
file's diff, so they live once in the toolbar instead of being
|
||||
repeated in every FileHeader. */}
|
||||
<DiffOptionsPopover />
|
||||
|
||||
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
|
||||
|
||||
{/* Sidebar tab toggles */}
|
||||
@@ -2417,9 +2367,6 @@ const ReviewApp: React.FC = () => {
|
||||
<FileTree
|
||||
files={files}
|
||||
activeFileIndex={activeFileIndex}
|
||||
onSelectSemanticDiff={() => openSemanticDiffPanel()}
|
||||
isSemanticDiffActive={isSemanticDiffActive}
|
||||
semanticDiffAvailable={semanticDiffAvailable}
|
||||
onSelectAllFiles={openAllFilesPanel}
|
||||
isAllFilesActive={isAllFilesActive}
|
||||
scrollHighlightIndex={isAllFilesActive && allFilesVisibleFile ? files.findIndex(f => f.path === allFilesVisibleFile) : undefined}
|
||||
|
||||
@@ -739,6 +739,12 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
|
||||
|
||||
// --- Collapse via CodeView item state (Diffshub pattern + anchor fix) ------
|
||||
|
||||
const [allCollapsed, setAllCollapsed] = useState(false);
|
||||
|
||||
// Reset the global collapse toggle when the file set changes — items re-seed
|
||||
// expanded on CodeView remount.
|
||||
useEffect(() => setAllCollapsed(false), [identity.items]);
|
||||
|
||||
const toggleItemCollapsed = useStableCallback((itemId: string) => {
|
||||
const handle = viewerRef.current;
|
||||
const viewer = handle?.getInstance();
|
||||
@@ -773,6 +779,37 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
|
||||
return viewerRef.current?.getItem(itemId)?.collapsed === true;
|
||||
}, []);
|
||||
|
||||
// Collapse or expand every file at once — driven by the floating bottom-left
|
||||
// toggle. Pins scroll to the first file when collapsing so the view doesn't
|
||||
// jump into empty space.
|
||||
const setAllItemsCollapsed = useStableCallback((collapsed: boolean) => {
|
||||
const handle = viewerRef.current;
|
||||
if (handle == null) return;
|
||||
for (const { id } of identity.items) {
|
||||
const item = handle.getItem(id);
|
||||
if (item == null || (item.collapsed === true) === collapsed) continue;
|
||||
item.collapsed = collapsed;
|
||||
item.version = (item.version ?? 0) + 1;
|
||||
handle.updateItem(item);
|
||||
}
|
||||
if (collapsed) {
|
||||
const first = identity.items[0]?.id;
|
||||
if (first) handle.getInstance()?.scrollTo({ type: 'item', id: first, align: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleAllCollapsed = useStableCallback(() => {
|
||||
const handle = viewerRef.current;
|
||||
if (handle == null) return;
|
||||
// If anything is open, collapse all; otherwise expand all. Computed from
|
||||
// live item state so it stays correct after manual per-file toggles.
|
||||
const anyExpanded = identity.items.some(
|
||||
({ id }) => handle.getItem(id)?.collapsed !== true,
|
||||
);
|
||||
setAllItemsCollapsed(anyExpanded);
|
||||
setAllCollapsed(anyExpanded);
|
||||
});
|
||||
|
||||
// Force CodeView to re-render an item's slots (header included) WITHOUT
|
||||
// otherwise mutating it. Pierre renders `renderCustomHeader` into a portal
|
||||
// driven by an internal store that only republishes on item mount / unmount /
|
||||
@@ -1775,6 +1812,10 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
|
||||
// callbacks), so file identity comes from context.item — no geometry or
|
||||
// active-file inference. Only wired when onCodeNavRequest is provided.
|
||||
...(onCodeNavRequest && {
|
||||
// Pierre's renderer-options builder drops onToken* before it evaluates
|
||||
// shouldUseTokenTransformer, so the handlers alone never wrap tokens
|
||||
// (no data-char) and token events never fire. Enable it explicitly.
|
||||
useTokenTransformer: true,
|
||||
onTokenClick(props, event, context) {
|
||||
handleTokenClick(props, event, context.item);
|
||||
},
|
||||
@@ -1822,6 +1863,40 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Floating collapse/expand-all toggle, pinned to the panel's bottom-left
|
||||
corner over the scrolling diff list. Only exists in all-files mode
|
||||
(this component renders only there). */}
|
||||
{identity.items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAllCollapsed}
|
||||
className="absolute bottom-0 left-0 z-30 flex items-center justify-center h-[var(--panel-header-h)] w-[var(--panel-header-h)] border-r border-t border-border bg-background text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={allCollapsed ? 'Expand all files' : 'Collapse all files'}
|
||||
aria-label={allCollapsed ? 'Expand all files' : 'Collapse all files'}
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<>
|
||||
<path d="M7 9l5-5 5 5" />
|
||||
<path d="M7 15l5 5 5-5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M7 4l5 5 5-5" />
|
||||
<path d="M7 20l5-5 5 5" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<CodeView<DiffAnnotationMetadata>
|
||||
// Remount on diff switch so uncontrolled `initialItems` re-seeds from
|
||||
// the freshly computed identity. Without this, switching diff
|
||||
|
||||
@@ -103,10 +103,10 @@ export const DiffOptionsPopover: React.FC = () => {
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-all flex items-center data-[state=open]:bg-primary/15 data-[state=open]:text-primary"
|
||||
className="px-2 py-1 rounded-md text-muted-foreground hover:text-foreground transition-colors flex items-center data-[state=open]:bg-background data-[state=open]:text-foreground data-[state=open]:shadow-sm"
|
||||
title="Diff display options"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
|
||||
@@ -76,6 +76,9 @@ const PierreDiffContent = React.memo(({
|
||||
themeType: pierreTheme.type,
|
||||
unsafeCSS: pierreTheme.css,
|
||||
...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }),
|
||||
// We render our own FileHeader above this view; suppress Pierre's
|
||||
// built-in header (and its file-status symbol) so it doesn't double up.
|
||||
disableFileHeader: true,
|
||||
diffStyle,
|
||||
overflow: diffOverflow,
|
||||
diffIndicators,
|
||||
@@ -88,6 +91,11 @@ const PierreDiffContent = React.memo(({
|
||||
enableGutterUtility: true,
|
||||
onGutterUtilityClick,
|
||||
onLineSelectionEnd,
|
||||
// Pierre's renderer-options builder drops onToken* before it evaluates
|
||||
// shouldUseTokenTransformer, so passing the handlers alone never wraps
|
||||
// tokens (no data-char) and code-nav/token events never fire. Enable
|
||||
// the token transformer explicitly.
|
||||
useTokenTransformer: true,
|
||||
onTokenClick,
|
||||
onTokenEnter,
|
||||
onTokenLeave,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { SemanticFileBadge } from './SemanticFileBadge';
|
||||
import { OpenInAppButton } from '@plannotator/ui/components/OpenInAppButton';
|
||||
import { useReviewStateOptional } from '../dock/ReviewStateContext';
|
||||
import type { DiffFileStatus } from '../types';
|
||||
|
||||
interface FileHeaderProps {
|
||||
@@ -46,41 +48,45 @@ function frontEllipsize(text: string, visibleChars: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change-type icon (added/deleted/renamed). 'modified' deliberately renders
|
||||
* nothing — most files are modifications, so only the others stand out
|
||||
* (mirrors Pierre's diffshub). Renamed uses diffshub's blue (#007aff).
|
||||
* Change-type letter shown to the right of the +/- counts (matches the file
|
||||
* tree): A added · D deleted · R renamed, colored so the critical changes pop.
|
||||
* Modified is bare — the +/- counts already say it changed within the file.
|
||||
*/
|
||||
const FileStatusIcon: React.FC<{ status: DiffFileStatus; oldPath?: string }> = ({ status, oldPath }) => {
|
||||
const STATUS_LETTER: Record<DiffFileStatus, { letter: string; className: string; title: string }> = {
|
||||
added: { letter: 'A', className: 'text-success', title: 'Added file' },
|
||||
modified: { letter: 'M', className: 'text-muted-foreground', title: 'Modified file' },
|
||||
deleted: { letter: 'D', className: 'text-destructive', title: 'Deleted file' },
|
||||
renamed: { letter: 'R', className: 'text-[#007aff]', title: 'Renamed file' },
|
||||
};
|
||||
|
||||
const FileStatusLetter: React.FC<{ status: DiffFileStatus; oldPath?: string }> = ({ status, oldPath }) => {
|
||||
// Match the file tree: only added/deleted/renamed get a badge; modified is
|
||||
// bare (the +/- counts already convey that it changed within the file).
|
||||
if (status === 'modified') return null;
|
||||
const meta =
|
||||
status === 'added'
|
||||
? { className: 'text-success', title: 'Added file' }
|
||||
: status === 'deleted'
|
||||
? { className: 'text-destructive', title: 'Deleted file' }
|
||||
: { className: 'text-[#007aff]', title: oldPath ? `Renamed from ${oldPath}` : 'Renamed file' };
|
||||
const meta = STATUS_LETTER[status];
|
||||
const title = status === 'renamed' && oldPath ? `Renamed from ${oldPath}` : meta.title;
|
||||
return (
|
||||
<span className={`flex-none mr-1.5 ${meta.className}`} title={meta.title} aria-label={meta.title}>
|
||||
{status === 'added' && (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path strokeLinecap="round" d="M12 8v8M8 12h8" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'deleted' && (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path strokeLinecap="round" d="M8 12h8" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'renamed' && (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12h16M14 6l6 6-6 6" />
|
||||
</svg>
|
||||
)}
|
||||
<span
|
||||
className={`flex-none font-semibold leading-none ${meta.className}`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
{meta.letter}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Count +/- lines in a unified patch (ignores the +++/--- file headers). */
|
||||
function countChanges(patch: string): { additions: number; deletions: number } {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line[0] === '+' && !line.startsWith('+++')) additions++;
|
||||
else if (line[0] === '-' && !line.startsWith('---')) deletions++;
|
||||
}
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
/** Sticky file header with file path, Viewed toggle, Git Add, and Copy Diff button */
|
||||
export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
filePath,
|
||||
@@ -99,13 +105,12 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
collapseToggle,
|
||||
onCollapseToggle,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [headerWidth, setHeaderWidth] = useState<number>(0);
|
||||
const state = useReviewStateOptional();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const fileCommentRef = useRef<HTMLButtonElement>(null);
|
||||
const { directory, name } = splitFilePath(filePath);
|
||||
const isCompact = headerWidth > 0 && headerWidth < 760;
|
||||
const isTight = headerWidth > 0 && headerWidth < 600;
|
||||
const isVeryTight = headerWidth > 0 && headerWidth < 480;
|
||||
const showFilenameOnly = headerWidth > 0 && headerWidth < 560;
|
||||
const truncatedName = showFilenameOnly
|
||||
@@ -128,9 +133,9 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
}, []);
|
||||
|
||||
const stageLabel = isVeryTight ? '' : isCompact ? (isStaging ? 'Adding' : isStaged ? 'Added' : 'Add') : (isStaging ? 'Adding...' : isStaged ? 'Added' : 'Git Add');
|
||||
const commentLabel = isVeryTight ? '' : isCompact ? 'Comment' : 'File Comment';
|
||||
const copyLabel = isTight ? '' : isCompact ? 'Copy' : copied ? 'Copied!' : 'Copy Diff';
|
||||
const commentLabel = isVeryTight ? '' : 'Comment';
|
||||
const viewedLabel = isVeryTight ? '' : 'Viewed';
|
||||
const { additions, deletions } = React.useMemo(() => countChanges(patch), [patch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -140,7 +145,6 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 items-center" onClick={onCollapseToggle} style={onCollapseToggle ? { cursor: 'pointer' } : undefined}>
|
||||
{collapseToggle}
|
||||
{status && <FileStatusIcon status={status} oldPath={oldPath} />}
|
||||
<span
|
||||
className="min-w-0 flex items-center text-xs font-semibold leading-none whitespace-nowrap"
|
||||
title={status === 'renamed' && oldPath ? `${oldPath} → ${filePath}` : filePath}
|
||||
@@ -174,6 +178,13 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
{truncatedName}
|
||||
</span>
|
||||
</span>
|
||||
{(additions > 0 || deletions > 0 || (status && status !== 'modified')) && (
|
||||
<span className="flex-none ml-2 flex items-center gap-1.5 text-xs leading-none">
|
||||
{additions > 0 && <span className="font-mono text-success">+{additions}</span>}
|
||||
{deletions > 0 && <span className="font-mono text-destructive">-{deletions}</span>}
|
||||
{status && <FileStatusLetter status={status} oldPath={oldPath} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex flex-shrink-0 items-center pl-2 ${isCompact ? 'gap-1' : 'gap-2'}`}>
|
||||
{onToggleViewed && (
|
||||
@@ -249,36 +260,17 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
|
||||
{commentLabel && <span>{commentLabel}</span>}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(patch);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}}
|
||||
className={`text-xs text-muted-foreground hover:text-foreground rounded hover:bg-muted transition-colors flex items-center ${copyLabel ? 'gap-1 px-2 py-1' : 'px-1.5 py-1'}`}
|
||||
title="Copy this file's diff"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{copyLabel && <span>{copyLabel}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copyLabel && <span>{copyLabel}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<SemanticFileBadge filePath={filePath} />
|
||||
{/* File actions: open in app (when launchable), copy path, copy file
|
||||
diff. canOpen=false in PR review without a local checkout — those
|
||||
files aren't on disk — but copy actions remain. */}
|
||||
<OpenInAppButton
|
||||
filePath={filePath}
|
||||
base={state?.agentCwd ?? null}
|
||||
diffText={patch}
|
||||
canOpen={!(state?.prMetadata && !state?.agentCwd) && status !== 'deleted'}
|
||||
showLabel={!isCompact}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { WorktreePicker } from './WorktreePicker';
|
||||
import { getReviewSearchSideLabel, type ReviewSearchFileGroup, type ReviewSearchMatch } from '../utils/reviewSearch';
|
||||
import type { DiffFile } from '../types';
|
||||
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
|
||||
import { SemanticDiffAccordion } from './SemanticDiffAccordion';
|
||||
|
||||
interface FileTreeProps {
|
||||
files: DiffFile[];
|
||||
@@ -60,9 +61,6 @@ interface FileTreeProps {
|
||||
activeSearchMatchId?: string | null;
|
||||
onSelectSearchMatch?: (matchId: string) => void;
|
||||
onStepSearchMatch?: (direction: 1 | -1) => void;
|
||||
onSelectSemanticDiff?: () => void;
|
||||
isSemanticDiffActive?: boolean;
|
||||
semanticDiffAvailable?: boolean;
|
||||
onSelectAllFiles?: () => void;
|
||||
isAllFilesActive?: boolean;
|
||||
scrollHighlightIndex?: number;
|
||||
@@ -115,9 +113,6 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
||||
activeSearchMatchId,
|
||||
onSelectSearchMatch,
|
||||
onStepSearchMatch,
|
||||
onSelectSemanticDiff,
|
||||
isSemanticDiffActive = false,
|
||||
semanticDiffAvailable = false,
|
||||
onSelectAllFiles,
|
||||
isAllFilesActive = false,
|
||||
scrollHighlightIndex,
|
||||
@@ -456,19 +451,6 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{semanticDiffAvailable && onSelectSemanticDiff && (
|
||||
<button
|
||||
onClick={onSelectSemanticDiff}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors mb-0.5 ${
|
||||
isSemanticDiffActive
|
||||
? 'bg-primary/15 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="w-3.5 h-3.5 flex flex-shrink-0 items-center justify-center" aria-hidden="true">∆</span>
|
||||
<span>Semantic diff</span>
|
||||
</button>
|
||||
)}
|
||||
{onSelectAllFiles && (
|
||||
<button
|
||||
onClick={onSelectAllFiles}
|
||||
@@ -495,7 +477,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
||||
node={node}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
activeFileIndex={isAllFilesActive || isSemanticDiffActive ? -1 : activeFileIndex}
|
||||
activeFileIndex={isAllFilesActive ? -1 : activeFileIndex}
|
||||
scrollHighlightIndex={isAllFilesActive ? scrollHighlightIndex : undefined}
|
||||
onSelectFile={onSelectFile}
|
||||
onDoubleClickFile={onDoubleClickFile}
|
||||
@@ -512,6 +494,8 @@ export const FileTree: React.FC<FileTreeProps> = ({
|
||||
</div>
|
||||
</OverlayScrollArea>
|
||||
|
||||
<SemanticDiffAccordion />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-2 py-1.5 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useReviewStateOptional } from '../dock/ReviewStateContext';
|
||||
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
|
||||
import {
|
||||
SemanticDiffRows,
|
||||
groupSemanticChangesByFile,
|
||||
lineSelectionForChange,
|
||||
} from '../dock/panels/semanticDiffShared';
|
||||
import { loadSemanticDiff } from '../hooks/useFileSemanticChanges';
|
||||
import type {
|
||||
SemanticDiffOkResponse,
|
||||
SemanticDiffChange,
|
||||
SemanticDiffBinaryChange,
|
||||
} from '@plannotator/shared/semantic-diff-types';
|
||||
|
||||
type LoadState =
|
||||
| { status: 'idle' | 'loading' | 'unavailable' | 'error' }
|
||||
| { status: 'ready' | 'empty'; data: SemanticDiffOkResponse };
|
||||
|
||||
/**
|
||||
* Sidebar-constrained semantic diff: the same entity rows as the dock panel,
|
||||
* collapsed into an accordion pinned at the bottom of the file tree. Reuses the
|
||||
* existing /api/semantic-diff endpoint and the shared SemanticDiffRows markup;
|
||||
* clicking a change navigates exactly like the panel/badge (openDiffFile +
|
||||
* line select). Self-contained via ReviewStateContext.
|
||||
*/
|
||||
export const SemanticDiffAccordion: React.FC = () => {
|
||||
const state = useReviewStateOptional();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loadState, setLoadState] = useState<LoadState>({ status: 'idle' });
|
||||
|
||||
// Vertical resize for the expanded body (reuses the sidebar resize hook on
|
||||
// the y-axis; drag the top handle up to grow the panel, double-click to reset).
|
||||
const resize = useResizablePanel({
|
||||
axis: 'y',
|
||||
side: 'bottom',
|
||||
storageKey: 'pn-semantic-diff-height',
|
||||
defaultWidth: 240,
|
||||
minWidth: 96,
|
||||
maxWidth: 500,
|
||||
});
|
||||
|
||||
const rawPatch = state?.rawPatch;
|
||||
const semanticDiffAvailable = state?.semanticDiffAvailable ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!semanticDiffAvailable) {
|
||||
setLoadState({ status: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
// Reuse the shared, per-patch cache the file-header badges use — one request
|
||||
// for both surfaces (with its retry/backoff) instead of a duplicate fetch.
|
||||
let cancelled = false;
|
||||
setLoadState({ status: 'loading' });
|
||||
loadSemanticDiff(rawPatch ?? '').then((data) => {
|
||||
if (cancelled) return;
|
||||
if (data.status === 'unavailable') {
|
||||
setLoadState({ status: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
setLoadState({ status: 'error' });
|
||||
return;
|
||||
}
|
||||
setLoadState(
|
||||
data.changes.length === 0 && data.binaryChanges.length === 0
|
||||
? { status: 'empty', data }
|
||||
: { status: 'ready', data },
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rawPatch, semanticDiffAvailable]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (loadState.status !== 'ready' && loadState.status !== 'empty') return [];
|
||||
return groupSemanticChangesByFile(loadState.data.changes, loadState.data.binaryChanges);
|
||||
}, [loadState]);
|
||||
|
||||
const count = useMemo(
|
||||
() => grouped.reduce((n, g) => n + g.changes.length + g.binaryChanges.length, 0),
|
||||
[grouped],
|
||||
);
|
||||
|
||||
const openChange = useCallback(
|
||||
(change: SemanticDiffChange) => {
|
||||
state?.openDiffFile(change.filePath);
|
||||
state?.onLineSelection(lineSelectionForChange(change));
|
||||
},
|
||||
[state],
|
||||
);
|
||||
const openBinaryChange = useCallback(
|
||||
(change: SemanticDiffBinaryChange) => {
|
||||
state?.openDiffFile(change.filePath);
|
||||
state?.onLineSelection(null);
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
if (!semanticDiffAvailable || loadState.status === 'unavailable') return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 flex-shrink-0">
|
||||
{open && (
|
||||
<div
|
||||
onPointerDown={resize.handleProps.onPointerDown}
|
||||
onDoubleClick={resize.handleProps.onDoubleClick}
|
||||
style={resize.handleProps.style}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
title="Drag to resize · double-click to reset"
|
||||
className={`h-1 -mt-px cursor-row-resize transition-colors ${
|
||||
resize.isDragging ? 'bg-primary/40' : 'hover:bg-border'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1 px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Semantic diff — changed functions, classes, and other entities"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="font-medium">Semantic diff</span>
|
||||
{count > 0 && <span className="ml-auto tabular-nums text-muted-foreground/70">{count}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="semantic-diff-accordion overflow-auto border-t border-border/40"
|
||||
style={{ height: resize.size }}
|
||||
>
|
||||
{loadState.status === 'loading' && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground/70">Loading…</div>
|
||||
)}
|
||||
{loadState.status === 'error' && (
|
||||
<div className="px-3 py-2 text-xs text-destructive">Semantic diff failed.</div>
|
||||
)}
|
||||
{(loadState.status === 'ready' || loadState.status === 'empty') && grouped.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground/70">No semantic changes.</div>
|
||||
)}
|
||||
{grouped.map((group) => {
|
||||
const slash = group.filePath.lastIndexOf('/');
|
||||
const dir = slash === -1 ? '' : group.filePath.slice(0, slash + 1);
|
||||
const name = slash === -1 ? group.filePath : group.filePath.slice(slash + 1);
|
||||
return (
|
||||
<section className="semantic-diff-file" key={group.filePath}>
|
||||
<header className="semantic-diff-file-header">
|
||||
<span className="semantic-diff-path" title={group.filePath}>
|
||||
{dir && <span className="semantic-diff-path-dir">{dir}</span>}
|
||||
<span className="semantic-diff-path-name">{name}</span>
|
||||
</span>
|
||||
</header>
|
||||
<div className="semantic-diff-rows">
|
||||
<SemanticDiffRows
|
||||
changes={group.changes}
|
||||
binaryChanges={group.binaryChanges}
|
||||
onOpenChange={openChange}
|
||||
onOpenBinary={openBinaryChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,6 +42,9 @@ export interface ReviewState {
|
||||
/** PR/MR review scope label, e.g. "Layer diff" or "Full stack diff". */
|
||||
prReviewScope?: string;
|
||||
prDiffScope?: PRDiffScope;
|
||||
/** Agent working directory — base for resolving repo-relative diff paths to
|
||||
* absolute (e.g. for the Open-in-app control). */
|
||||
agentCwd?: string | null;
|
||||
|
||||
// Annotations
|
||||
allAnnotations: CodeAnnotation[];
|
||||
@@ -108,11 +111,7 @@ export interface ReviewState {
|
||||
openDiffFile: (filePath: string) => void;
|
||||
onAllFilesVisibleFileChange: (filePath: string | null) => void;
|
||||
isAllFilesActive: boolean;
|
||||
isSemanticDiffActive: boolean;
|
||||
semanticDiffAvailable: boolean;
|
||||
onSemanticDiffUnavailable: () => void;
|
||||
onSemanticDiffLoadError: () => boolean;
|
||||
onSemanticDiffLoadSuccess: () => void;
|
||||
|
||||
// Tour
|
||||
openTourPanel: (jobId: string) => void;
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type {
|
||||
SemanticDiffBinaryChange,
|
||||
SemanticDiffChange,
|
||||
SemanticDiffResponse,
|
||||
} from '@plannotator/shared/semantic-diff-types';
|
||||
import { useReviewState } from '../ReviewStateContext';
|
||||
import {
|
||||
SemanticDiffRows,
|
||||
groupSemanticChangesByFile,
|
||||
lineSelectionForChange,
|
||||
} from './semanticDiffShared';
|
||||
|
||||
type SemanticDiffOkResponse = Extract<SemanticDiffResponse, { status: 'ok' }>;
|
||||
type SemanticDiffErrorResponse = Extract<SemanticDiffResponse, { status: 'error' }>;
|
||||
|
||||
type LoadState =
|
||||
| { status: 'idle' | 'loading' }
|
||||
| { status: 'ready'; data: SemanticDiffOkResponse }
|
||||
| { status: 'empty'; data: SemanticDiffOkResponse }
|
||||
| { status: 'error'; error: SemanticDiffErrorResponse | Error };
|
||||
|
||||
function formatSummary(data: SemanticDiffOkResponse): string {
|
||||
const summary = data.summary;
|
||||
const parts = [
|
||||
`${summary.added} added`,
|
||||
`${summary.modified} modified`,
|
||||
`${summary.deleted} deleted`,
|
||||
];
|
||||
if (summary.renamed > 0) parts.push(`${summary.renamed} renamed`);
|
||||
if (summary.moved > 0) parts.push(`${summary.moved} moved`);
|
||||
if (summary.reordered > 0) parts.push(`${summary.reordered} reordered`);
|
||||
if (summary.binary > 0) parts.push(`${summary.binary} binary`);
|
||||
if (summary.orphan > 0) parts.push(`${summary.orphan} orphans`);
|
||||
return `Summary: ${parts.join(', ')} across ${summary.fileCount} files`;
|
||||
}
|
||||
|
||||
function formatLoadError(error: SemanticDiffErrorResponse | Error): string {
|
||||
return error.message || 'Semantic diff failed.';
|
||||
}
|
||||
|
||||
function splitFilePath(filePath: string): { dir: string; name: string } {
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
if (lastSlash === -1) return { dir: '', name: filePath };
|
||||
return { dir: filePath.slice(0, lastSlash + 1), name: filePath.slice(lastSlash + 1) };
|
||||
}
|
||||
|
||||
export function ReviewSemanticDiffPanel() {
|
||||
const state = useReviewState();
|
||||
const {
|
||||
rawPatch,
|
||||
semanticDiffAvailable,
|
||||
onSemanticDiffUnavailable,
|
||||
onSemanticDiffLoadError,
|
||||
onSemanticDiffLoadSuccess,
|
||||
openDiffFile,
|
||||
onLineSelection,
|
||||
} = state;
|
||||
const [loadState, setLoadState] = useState<LoadState>({ status: 'idle' });
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!semanticDiffAvailable) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoadState({ status: 'loading' });
|
||||
|
||||
fetch('/api/semantic-diff', { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Semantic diff failed');
|
||||
return res.json() as Promise<SemanticDiffResponse>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (data.status === 'unavailable') {
|
||||
onSemanticDiffUnavailable();
|
||||
return;
|
||||
}
|
||||
if (data.status === 'error') {
|
||||
if (onSemanticDiffLoadError()) return;
|
||||
setLoadState({ status: 'error', error: data });
|
||||
return;
|
||||
}
|
||||
onSemanticDiffLoadSuccess();
|
||||
setLoadState(data.changes.length === 0 && data.binaryChanges.length === 0
|
||||
? { status: 'empty', data }
|
||||
: { status: 'ready', data });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error('Failed to load semantic diff:', error);
|
||||
if (onSemanticDiffLoadError()) return;
|
||||
setLoadState({ status: 'error', error: error instanceof Error ? error : new Error(String(error)) });
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
rawPatch,
|
||||
retryCount,
|
||||
semanticDiffAvailable,
|
||||
onSemanticDiffUnavailable,
|
||||
onSemanticDiffLoadError,
|
||||
onSemanticDiffLoadSuccess,
|
||||
]);
|
||||
|
||||
const groupedChanges = useMemo(() => {
|
||||
if (loadState.status !== 'ready' && loadState.status !== 'empty') return [];
|
||||
return groupSemanticChangesByFile(loadState.data.changes, loadState.data.binaryChanges);
|
||||
}, [loadState]);
|
||||
|
||||
const openChange = useCallback((change: SemanticDiffChange) => {
|
||||
openDiffFile(change.filePath);
|
||||
onLineSelection(lineSelectionForChange(change));
|
||||
}, [openDiffFile, onLineSelection]);
|
||||
|
||||
const openBinaryChange = useCallback((change: SemanticDiffBinaryChange) => {
|
||||
openDiffFile(change.filePath);
|
||||
onLineSelection(null);
|
||||
}, [openDiffFile, onLineSelection]);
|
||||
|
||||
if (!semanticDiffAvailable) return null;
|
||||
|
||||
if (loadState.status === 'idle' || loadState.status === 'loading') {
|
||||
return (
|
||||
<div className="semantic-diff-panel">
|
||||
<div className="semantic-diff-terminal" aria-live="polite">
|
||||
<div className="semantic-diff-loading">Running semantic diff...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadState.status === 'error') {
|
||||
return (
|
||||
<div className="semantic-diff-panel">
|
||||
<div className="semantic-diff-terminal" aria-live="polite">
|
||||
<div className="semantic-diff-error" role="alert">
|
||||
Semantic diff failed: {formatLoadError(loadState.error)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="semantic-diff-retry"
|
||||
onClick={() => setRetryCount((count) => count + 1)}
|
||||
>
|
||||
↻ retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="semantic-diff-panel">
|
||||
<div className="semantic-diff-terminal" aria-label="Semantic diff">
|
||||
{groupedChanges.map((group) => (
|
||||
<section className="semantic-diff-file" key={group.filePath}>
|
||||
<header className="semantic-diff-file-header">
|
||||
<span className="semantic-diff-path" title={group.filePath}>
|
||||
{(() => {
|
||||
const { dir, name } = splitFilePath(group.filePath);
|
||||
return (
|
||||
<>
|
||||
{dir && <span className="semantic-diff-path-dir">{dir}</span>}
|
||||
<span className="semantic-diff-path-name">{name}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</header>
|
||||
<div className="semantic-diff-rows">
|
||||
<SemanticDiffRows
|
||||
changes={group.changes}
|
||||
binaryChanges={group.binaryChanges}
|
||||
onOpenChange={openChange}
|
||||
onOpenBinary={openBinaryChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{loadState.status === 'empty' && (
|
||||
<div className="semantic-diff-empty">No semantic changes found.</div>
|
||||
)}
|
||||
<div className="semantic-diff-summary">{formatSummary(loadState.data)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { ReviewAgentJobDetailPanel } from './panels/ReviewAgentJobDetailPanel';
|
||||
import { ReviewPRSummaryPanel } from './panels/ReviewPRSummaryPanel';
|
||||
import { ReviewPRCommentsPanel } from './panels/ReviewPRCommentsPanel';
|
||||
import { ReviewPRChecksPanel } from './panels/ReviewPRChecksPanel';
|
||||
import { ReviewSemanticDiffPanel } from './panels/ReviewSemanticDiffPanel';
|
||||
import { ReviewAllFilesDiffPanel } from './panels/ReviewAllFilesDiffPanel';
|
||||
import { ReviewCodeNavPanel } from './panels/ReviewCodeNavPanel';
|
||||
|
||||
@@ -18,7 +17,6 @@ export const reviewPanelComponents = {
|
||||
[REVIEW_PANEL_TYPES.PR_SUMMARY]: ReviewPRSummaryPanel,
|
||||
[REVIEW_PANEL_TYPES.PR_COMMENTS]: ReviewPRCommentsPanel,
|
||||
[REVIEW_PANEL_TYPES.PR_CHECKS]: ReviewPRChecksPanel,
|
||||
[REVIEW_PANEL_TYPES.SEMANTIC_DIFF]: ReviewSemanticDiffPanel,
|
||||
[REVIEW_PANEL_TYPES.ALL_FILES]: ReviewAllFilesDiffPanel,
|
||||
[REVIEW_PANEL_TYPES.CODE_NAV]: ReviewCodeNavPanel,
|
||||
} as const;
|
||||
|
||||
@@ -11,7 +11,6 @@ export const REVIEW_PANEL_TYPES = {
|
||||
PR_SUMMARY: 'review-pr-summary',
|
||||
PR_COMMENTS: 'review-pr-comments',
|
||||
PR_CHECKS: 'review-pr-checks',
|
||||
SEMANTIC_DIFF: 'review-semantic-diff',
|
||||
ALL_FILES: 'review-all-files',
|
||||
CODE_NAV: 'review-code-nav',
|
||||
} as const;
|
||||
@@ -28,7 +27,6 @@ export const makeReviewAgentJobPanelId = (jobId: string) =>
|
||||
export const REVIEW_PR_SUMMARY_PANEL_ID = 'review-pr-summary';
|
||||
export const REVIEW_PR_COMMENTS_PANEL_ID = 'review-pr-comments';
|
||||
export const REVIEW_PR_CHECKS_PANEL_ID = 'review-pr-checks';
|
||||
export const REVIEW_SEMANTIC_DIFF_PANEL_ID = 'review-semantic-diff';
|
||||
export const REVIEW_ALL_FILES_PANEL_ID = 'review-all-files';
|
||||
export const REVIEW_CODE_NAV_PANEL_ID = 'review-code-nav';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
@@ -26,14 +26,23 @@ export interface DiffFreshness {
|
||||
export function useDiffFreshness({
|
||||
enabled,
|
||||
resetKey,
|
||||
onAgentCwd,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
/** Identity of the current diff snapshot (e.g. the rawPatch string). A new
|
||||
* snapshot (refresh / switch) clears staleness + dismissal and resumes. */
|
||||
resetKey: string;
|
||||
/** Called when a probe re-advertises the PR-mode local checkout (or null when
|
||||
* none is usable yet), so the Open-in control tracks pool warmup / in-place PR
|
||||
* switches without a page reload. A probe that omits the field leaves the
|
||||
* current value untouched (non-PR sessions never send it). */
|
||||
onAgentCwd?: (cwd: string | null) => void;
|
||||
}): DiffFreshness {
|
||||
const [staleFingerprint, setStaleFingerprint] = useState<string | null>(null);
|
||||
const [dismissedFingerprint, setDismissedFingerprint] = useState<string | null>(null);
|
||||
// Latest callback in a ref so the polling effect never resubscribes for it.
|
||||
const onAgentCwdRef = useRef(onAgentCwd);
|
||||
onAgentCwdRef.current = onAgentCwd;
|
||||
|
||||
// New snapshot → clean slate.
|
||||
useEffect(() => {
|
||||
@@ -59,11 +68,18 @@ export function useDiffFreshness({
|
||||
try {
|
||||
const res = await fetch('/api/diff/fresh');
|
||||
if (!cancelled && res.ok) {
|
||||
const data = (await res.json()) as { fresh: boolean; fingerprint?: string };
|
||||
const data = (await res.json()) as {
|
||||
fresh: boolean;
|
||||
fingerprint?: string;
|
||||
agentCwd?: string | null;
|
||||
};
|
||||
// Keep polling even while stale: a reverted edit flips back to
|
||||
// fresh, and a FURTHER change updates the fingerprint so a
|
||||
// dismissed notice can reappear.
|
||||
setStaleFingerprint(data.fresh ? null : data.fingerprint ?? 'stale');
|
||||
// PR mode re-advertises the live local checkout each probe; non-PR
|
||||
// probes omit the field entirely (leave agentCwd untouched).
|
||||
if ('agentCwd' in data) onAgentCwdRef.current?.(data.agentCwd ?? null);
|
||||
}
|
||||
} catch {
|
||||
// Transient/network/server-gone: ignore — staleness is best-effort.
|
||||
|
||||
@@ -32,7 +32,7 @@ async function fetchSemanticDiff(): Promise<SemanticDiffResponse> {
|
||||
return res.json() as Promise<SemanticDiffResponse>;
|
||||
}
|
||||
|
||||
function loadSemanticDiff(rawPatch: string): Promise<SemanticDiffResponse> {
|
||||
export function loadSemanticDiff(rawPatch: string): Promise<SemanticDiffResponse> {
|
||||
if (cacheKey === rawPatch && cachePromise) return cachePromise;
|
||||
cacheKey = rawPatch;
|
||||
|
||||
|
||||
@@ -329,6 +329,26 @@ diffs-container {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sidebar accordion: compact the panel rows so they fit the narrow file tree
|
||||
(the panel sized them via .semantic-diff-terminal, which we don't use here). */
|
||||
.semantic-diff-accordion {
|
||||
font-family: var(--diff-font-override, var(--font-mono));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.semantic-diff-accordion .semantic-diff-file {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.semantic-diff-accordion .semantic-diff-file-header {
|
||||
padding: 0 0.375rem 0.25rem;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.semantic-diff-accordion .semantic-diff-row {
|
||||
min-height: 1.5rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
|
||||
.semantic-diff-retry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -25,6 +25,12 @@ const poolOptions: WorkerPoolOptions = {
|
||||
|
||||
const highlighterOptions: WorkerInitializationRenderOptions = {
|
||||
preferredHighlighter: 'shiki-js',
|
||||
// Wrap tokens with `data-char` so token-level interactions work (Cmd+click
|
||||
// code navigation, token hover). Highlighting runs in the worker, and the
|
||||
// worker's render options — NOT the per-component onToken*/useTokenTransformer
|
||||
// options — decide whether the token transformer runs. Without this, tokens
|
||||
// render highlighted but un-interactable and code-nav silently no-ops.
|
||||
useTokenTransformer: true,
|
||||
// Preload the common languages; anything else resolves on demand.
|
||||
langs: ['typescript', 'tsx', 'javascript', 'json', 'css', 'html', 'python', 'go', 'rust', 'sh', 'yaml', 'markdown'],
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import { existsSync } from "fs";
|
||||
import { dirname, resolve as resolvePath } from "path";
|
||||
import { isWithinDirectory } from "@plannotator/shared/html-assets-node";
|
||||
import { isWSL } from "./browser";
|
||||
import { handleOpenInApps, handleOpenIn } from "./open-in";
|
||||
import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime";
|
||||
import type { AIEndpoints } from "@plannotator/ai";
|
||||
import { createHtmlAssetRegistry } from "./html-assets";
|
||||
@@ -328,6 +329,30 @@ export async function startAnnotateServer(
|
||||
return loadShareHtml(url.searchParams.get("path"));
|
||||
}
|
||||
|
||||
// API: List apps the host can open a file in (Open in App control).
|
||||
if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
||||
// A URL annotation source has no local file to open — mirror Pi and
|
||||
// report unavailable so the UI hides the control entirely.
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
return Response.json({ available: false, apps: [] });
|
||||
}
|
||||
return handleOpenInApps();
|
||||
}
|
||||
|
||||
// API: Open the annotated file in an app. A URL source has no local
|
||||
// file; any other open is confined to the same reference roots
|
||||
// /api/doc serves from, so any linked doc the user can view can also
|
||||
// be opened — and nothing outside the session can.
|
||||
if (url.pathname === "/api/open-in" && req.method === "POST") {
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Open in app is unavailable for this source" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return handleOpenIn(req, { resolveRoot: getReferenceRootPaths });
|
||||
}
|
||||
|
||||
// API: Update user config (write-back to ~/.plannotator/config.json)
|
||||
if (url.pathname === "/api/config" && req.method === "POST") {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveOpenInTarget } from "@plannotator/shared/html-assets-node";
|
||||
|
||||
// resolveOpenInTarget is the security boundary for POST /api/open-in: it decides
|
||||
// which absolute file a launch is allowed to touch. Real temp dirs/files are
|
||||
// used so the realpath-based symlink containment (isWithinDirectory) actually runs.
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
function makeDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "open-in-test-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
afterAll(() => {
|
||||
for (const dir of tempDirs) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort cleanup */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveOpenInTarget — /api/open-in containment", () => {
|
||||
test("a server root scopes opens: a file inside the root is allowed", () => {
|
||||
const root = makeDir();
|
||||
writeFileSync(join(root, "notes.md"), "x");
|
||||
expect(resolveOpenInTarget("notes.md", null, () => root)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("empty server roots deny opens while PR checkout warmup is pending", () => {
|
||||
// resolveOpenInRoot returns [] in PR pool warmup; that must deny, not fall back.
|
||||
expect(resolveOpenInTarget("notes.md", null, () => [])).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects relative traversal that escapes the root", () => {
|
||||
const root = makeDir();
|
||||
expect(resolveOpenInTarget("../escape.md", null, () => root)).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an arbitrary absolute path", () => {
|
||||
const root = makeDir();
|
||||
expect(resolveOpenInTarget("/etc/passwd", null, () => root)).toBeNull();
|
||||
});
|
||||
|
||||
test("a server root overrides a malicious client base", () => {
|
||||
const root = makeDir();
|
||||
writeFileSync(join(root, "inside.md"), "x");
|
||||
// base "/" would otherwise let anything through; the server root must win.
|
||||
expect(resolveOpenInTarget("/etc/passwd", "/", () => root)).toBeNull();
|
||||
expect(resolveOpenInTarget("inside.md", "/", () => root)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an in-root symlink that points outside the root", () => {
|
||||
const root = makeDir();
|
||||
const outside = makeDir();
|
||||
writeFileSync(join(outside, "secret.txt"), "x");
|
||||
try {
|
||||
symlinkSync(join(outside, "secret.txt"), join(root, "link.txt"));
|
||||
} catch {
|
||||
return; // platform without symlink permission (e.g. Windows CI) — skip
|
||||
}
|
||||
expect(resolveOpenInTarget("link.txt", null, () => root)).toBeNull();
|
||||
});
|
||||
|
||||
test("with no server root, an absolute path resolves against its own dir", () => {
|
||||
const root = makeDir();
|
||||
writeFileSync(join(root, "file.md"), "x");
|
||||
// Documents the default (review supplies resolveAgentCwd; this is the fallback).
|
||||
expect(resolveOpenInTarget(join(root, "file.md"), null, undefined)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("accepts a file in any of several roots (annotate reference roots)", () => {
|
||||
const a = makeDir();
|
||||
const b = makeDir();
|
||||
writeFileSync(join(b, "doc.md"), "x");
|
||||
// A linked doc living in root B is allowed because B is one of the roots
|
||||
// (mirrors /api/doc serving from cwd + the source-file dir).
|
||||
expect(resolveOpenInTarget(join(b, "doc.md"), null, () => [a, b])).not.toBeNull();
|
||||
// Outside every allowed root → still rejected.
|
||||
expect(resolveOpenInTarget("/etc/passwd", null, () => [a, b])).toBeNull();
|
||||
});
|
||||
|
||||
test("multi-root resolves relative paths per-root and rejects cross-root traversal", () => {
|
||||
const a = makeDir();
|
||||
const b = makeDir();
|
||||
writeFileSync(join(a, "x.md"), "x");
|
||||
// A relative path resolves within a root that contains it.
|
||||
expect(resolveOpenInTarget("x.md", null, () => [a, b])).not.toBeNull();
|
||||
// A traversal can't escape one root by landing inside another.
|
||||
expect(resolveOpenInTarget("../x.md", null, () => [a, b])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Open-in-App launcher (Bun runtime).
|
||||
*
|
||||
* Cross-platform "open this file in <app>" helper, modeled on
|
||||
* `packages/server/browser.ts` (openBrowser) and `packages/server/ide.ts`
|
||||
* (openEditorDiff). Uses argv arrays via `Bun.spawn` — never shell string
|
||||
* interpolation — to avoid command injection.
|
||||
*
|
||||
* The app catalog is the single source of truth at
|
||||
* `@plannotator/shared/open-in-apps`. `kind` drives launch semantics:
|
||||
* - file-manager (reveal) -> reveal the file in the OS file manager
|
||||
* - editor -> open the file itself
|
||||
* - terminal -> open the file's parent directory
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import {
|
||||
OPEN_IN_APPS,
|
||||
getOpenInApp,
|
||||
resolveRevealLabel,
|
||||
resolveRevealIcon,
|
||||
type OpenInApp,
|
||||
type OpenInKind,
|
||||
type OpenInPlatform,
|
||||
} from "@plannotator/shared/open-in-apps";
|
||||
import { resolveOpenInTarget } from "@plannotator/shared/html-assets-node";
|
||||
import { isRemoteSession } from "./remote";
|
||||
|
||||
export type OpenInLaunchResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
function currentPlatform(): OpenInPlatform {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "mac";
|
||||
case "win32":
|
||||
return "win";
|
||||
default:
|
||||
return "linux";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an argv command without a shell. Resolves to a launch result, surfacing
|
||||
* ENOENT (app/binary not found) as a friendly error.
|
||||
*/
|
||||
async function runArgv(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
friendlyName: string,
|
||||
opts?: { cwd?: string },
|
||||
): Promise<OpenInLaunchResult> {
|
||||
try {
|
||||
const proc = Bun.spawn([cmd, ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
...(opts?.cwd && { cwd: opts.cwd }),
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
if (/not found|ENOENT/i.test(stderr)) {
|
||||
return { ok: false, error: `${friendlyName} not found` };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to open ${friendlyName} (exit ${exitCode})${stderr ? `: ${stderr.trim()}` : ""}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/ENOENT|not found/i.test(msg)) {
|
||||
return { ok: false, error: `${friendlyName} not found` };
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a launcher we can't meaningfully await — e.g. Windows `explorer`, which
|
||||
* exits non-zero even on success. Returns ok unless the spawn itself throws.
|
||||
*/
|
||||
function spawnDetached(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
friendlyName: string,
|
||||
): Promise<OpenInLaunchResult> {
|
||||
try {
|
||||
Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore" });
|
||||
return Promise.resolve({ ok: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/ENOENT|not found/i.test(msg)) {
|
||||
return Promise.resolve({ ok: false, error: `${friendlyName} not found` });
|
||||
}
|
||||
return Promise.resolve({ ok: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the system-default handler for a path.
|
||||
*/
|
||||
function openSystemDefault(target: string): Promise<OpenInLaunchResult> {
|
||||
const platform = currentPlatform();
|
||||
if (platform === "mac") {
|
||||
return runArgv("open", [target], "default app");
|
||||
}
|
||||
if (platform === "win") {
|
||||
// `start` is a cmd builtin; the empty-string title arg avoids the quoted
|
||||
// target being treated as a window title.
|
||||
return runArgv("cmd", ["/c", "start", "", path.basename(target)], "default app", {
|
||||
cwd: path.dirname(target),
|
||||
});
|
||||
}
|
||||
return runArgv("xdg-open", [target], "default app");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal a file in the OS file manager.
|
||||
*/
|
||||
function revealFile(absPath: string): Promise<OpenInLaunchResult> {
|
||||
const platform = currentPlatform();
|
||||
if (platform === "mac") {
|
||||
return runArgv("open", ["-R", absPath], "Finder");
|
||||
}
|
||||
if (platform === "win") {
|
||||
// explorer.exe exits non-zero even on success; launch fire-and-forget.
|
||||
return spawnDetached("explorer", [`/select,${absPath}`], "Explorer");
|
||||
}
|
||||
return runArgv("xdg-open", [path.dirname(absPath)], "file manager");
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch an editor/terminal app from the catalog.
|
||||
* - editor -> open the file itself
|
||||
* - terminal -> open the file's parent directory
|
||||
*/
|
||||
function openWithApp(
|
||||
app: OpenInApp,
|
||||
absPath: string,
|
||||
): Promise<OpenInLaunchResult> {
|
||||
const platform = currentPlatform();
|
||||
const target = app.kind === "terminal" ? path.dirname(absPath) : absPath;
|
||||
|
||||
if (platform === "mac") {
|
||||
const appName = app.mac?.appName;
|
||||
if (!appName) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `${app.label} is not available on macOS`,
|
||||
});
|
||||
}
|
||||
return runArgv("open", ["-a", appName, target], app.label);
|
||||
}
|
||||
|
||||
if (platform === "win") {
|
||||
const bin = app.win?.bin;
|
||||
if (!bin) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `${app.label} is not available on Windows`,
|
||||
});
|
||||
}
|
||||
if (app.kind === "terminal") {
|
||||
// Open a new console window for the terminal. The directory is passed via
|
||||
// cwd (NOT a cmd argument) so a repo-controlled path never reaches cmd's
|
||||
// parser; `start` inherits that cwd. bin is a trusted catalog value.
|
||||
return runArgv("cmd", ["/c", "start", "", bin], app.label, { cwd: target });
|
||||
}
|
||||
return runArgv(bin, [target], app.label);
|
||||
}
|
||||
|
||||
const bin = app.linux?.bin;
|
||||
if (!bin) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
error: `${app.label} is not available on Linux`,
|
||||
});
|
||||
}
|
||||
return runArgv(bin, [target], app.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file in the given app (by catalog id). An unknown or undefined id
|
||||
* falls back to the OS default handler.
|
||||
*/
|
||||
export async function openFileInApp(
|
||||
absPath: string,
|
||||
appId?: string,
|
||||
): Promise<OpenInLaunchResult> {
|
||||
if (!appId) {
|
||||
return openSystemDefault(absPath);
|
||||
}
|
||||
|
||||
const app = getOpenInApp(appId);
|
||||
if (!app) {
|
||||
// Unknown id — fall back to system default.
|
||||
return openSystemDefault(absPath);
|
||||
}
|
||||
|
||||
if (app.kind === "file-manager") {
|
||||
return revealFile(absPath);
|
||||
}
|
||||
|
||||
return openWithApp(app, absPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a macOS app bundle named `<appName>.app` exists in one of the
|
||||
* standard application directories.
|
||||
*/
|
||||
function macAppBundleExists(appName: string): boolean {
|
||||
const bundle = `${appName}.app`;
|
||||
const candidates = [
|
||||
path.join("/Applications", bundle),
|
||||
path.join(os.homedir(), "Applications", bundle),
|
||||
path.join("/System/Applications", bundle),
|
||||
// Terminal.app and other built-ins live in the Utilities subfolder.
|
||||
path.join("/System/Applications/Utilities", bundle),
|
||||
];
|
||||
return candidates.some((p) => {
|
||||
try {
|
||||
return fs.existsSync(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given catalog app is launchable on this host.
|
||||
* - 'reveal' is always available.
|
||||
* - mac: the `.app` bundle exists (we launch via `open -a "<appName>"`).
|
||||
* - win/linux: its bin resolves on PATH.
|
||||
*/
|
||||
function isAppAvailable(app: OpenInApp, platform: OpenInPlatform): boolean {
|
||||
if (app.id === "reveal") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (platform === "mac") {
|
||||
// We launch via `open -a "<appName>"`, so availability must mean the .app
|
||||
// bundle is present — a CLI shim on PATH without the bundle would show the
|
||||
// app in the menu and then fail to launch.
|
||||
const appName = app.mac?.appName;
|
||||
return !!appName && macAppBundleExists(appName);
|
||||
}
|
||||
|
||||
if (platform === "win") {
|
||||
const bin = app.win?.bin;
|
||||
return !!bin && !!Bun.which(bin);
|
||||
}
|
||||
|
||||
// linux
|
||||
const bin = app.linux?.bin;
|
||||
return !!bin && !!Bun.which(bin);
|
||||
}
|
||||
|
||||
export interface AvailableOpenInApp {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: OpenInKind;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog filtered to apps launchable on this host, in catalog order,
|
||||
* with per-platform label/icon resolved for the 'reveal' entry. Always
|
||||
* includes 'reveal'.
|
||||
*/
|
||||
export function getAvailableOpenInApps(): AvailableOpenInApp[] {
|
||||
const platform = currentPlatform();
|
||||
const result: AvailableOpenInApp[] = [];
|
||||
|
||||
for (const app of OPEN_IN_APPS) {
|
||||
if (!isAppAvailable(app, platform)) continue;
|
||||
|
||||
let label = app.label;
|
||||
let icon = app.icon;
|
||||
if (app.id === "reveal") {
|
||||
label = resolveRevealLabel(platform);
|
||||
icon = resolveRevealIcon(platform);
|
||||
}
|
||||
|
||||
result.push({ id: app.id, label, kind: app.kind, icon });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/open-in/apps handler.
|
||||
*
|
||||
* `available` is false in remote/headless sessions (the UI hides the control
|
||||
* entirely). `apps` is the host-filtered catalog (always includes 'reveal');
|
||||
* empty when unavailable.
|
||||
*/
|
||||
export function handleOpenInApps(): Response {
|
||||
if (isRemoteSession()) {
|
||||
return Response.json({ available: false, apps: [] });
|
||||
}
|
||||
return Response.json({ available: true, apps: getAvailableOpenInApps() });
|
||||
}
|
||||
|
||||
export interface HandleOpenInOptions {
|
||||
/**
|
||||
* Server-supplied resolution root, used INSTEAD of the client-provided
|
||||
* `base`. The review server passes `resolveAgentCwd()` here so repo-relative
|
||||
* `git diff` paths resolve against the VCS root rather than the launch cwd
|
||||
* (which differs when `plannotator review` runs from a subdirectory).
|
||||
* When omitted, the handler falls back to the client `base`. May return
|
||||
* several roots (annotate passes the session's reference roots).
|
||||
*/
|
||||
resolveRoot?: () => string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/open-in handler. Resolves + containment-checks the target via
|
||||
* resolveOpenInTarget (shared), then launches via openFileInApp.
|
||||
*/
|
||||
export async function handleOpenIn(
|
||||
req: Request,
|
||||
options: HandleOpenInOptions = {},
|
||||
): Promise<Response> {
|
||||
if (isRemoteSession()) {
|
||||
return Response.json(
|
||||
{ ok: false, error: "Open in app is unavailable in remote sessions" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: { filePath?: unknown; base?: unknown; appId?: unknown };
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return Response.json({ ok: false, error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = typeof body.filePath === "string" ? body.filePath : "";
|
||||
if (!filePath) {
|
||||
return Response.json({ ok: false, error: "Missing filePath" }, { status: 400 });
|
||||
}
|
||||
const base = typeof body.base === "string" ? body.base : null;
|
||||
const appId = typeof body.appId === "string" ? body.appId : undefined;
|
||||
|
||||
const abs = resolveOpenInTarget(filePath, base, options.resolveRoot);
|
||||
if (abs == null) {
|
||||
return Response.json({ ok: false, error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await openFileInApp(abs, appId);
|
||||
// A failed launch is a valid request with the result in the body (ok:false),
|
||||
// not a server error — return 200 and let the client read `ok`. Matches Pi.
|
||||
return Response.json(result);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import { type PRMetadata, type PRReviewFileComment, type PRStackTree, type PRLis
|
||||
import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime";
|
||||
import type { AIEndpoints } from "@plannotator/ai";
|
||||
import { isWSL } from "./browser";
|
||||
import { handleOpenInApps, handleOpenIn } from "./open-in";
|
||||
import type { LocalWorkspaceReview, WorkspaceDiffType } from "./review-workspace";
|
||||
import { handleCodeNavResolve, extractChangedFiles } from "./code-nav";
|
||||
|
||||
@@ -338,6 +339,16 @@ export async function startReviewServer(
|
||||
}
|
||||
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd) ?? process.cwd();
|
||||
};
|
||||
// Strict launch root for /api/open-in: in PR pool mode only the PR's own
|
||||
// checkout is acceptable — never the launch-repo fallback resolveAgentCwd
|
||||
// uses, which would open a file from the wrong tree. Returns [] until the
|
||||
// checkout is ready so resolveOpenInTarget rejects (the button is gated off
|
||||
// then anyway); non-PR resolves to the working tree as usual.
|
||||
const resolveOpenInRoot = (): string | string[] => {
|
||||
if (workspace) return workspace.root;
|
||||
if (options.worktreePool && prMetadata) return resolvePRLocalCwd() ?? [];
|
||||
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd) ?? process.cwd();
|
||||
};
|
||||
// Async sibling of resolveAgentCwd: waits for the current PR's checkout
|
||||
// warmup instead of falling back while it is still being created.
|
||||
const resolveAgentCwdReady = async (): Promise<string> => {
|
||||
@@ -751,8 +762,16 @@ export async function startReviewServer(
|
||||
shareBaseUrl,
|
||||
repoInfo,
|
||||
isWSL: wslFlag,
|
||||
...(options.agentCwd && { agentCwd: options.agentCwd }),
|
||||
...(workspace && { agentCwd: workspace.root }),
|
||||
// PR mode advertises the ready PR checkout (null while warming), so
|
||||
// the Open-in button gates correctly from the initial load — not
|
||||
// the launch repo. Non-PR keeps the workspace/local cwd.
|
||||
...(isPRMode
|
||||
? { agentCwd: resolvePRLocalCwd() ?? null }
|
||||
: workspace
|
||||
? { agentCwd: workspace.root }
|
||||
: options.agentCwd
|
||||
? { agentCwd: options.agentCwd }
|
||||
: {}),
|
||||
...(isPRMode && {
|
||||
prMetadata,
|
||||
platformUser,
|
||||
@@ -769,21 +788,41 @@ export async function startReviewServer(
|
||||
});
|
||||
}
|
||||
|
||||
// API: List apps the host can open a file in (Open in App control).
|
||||
if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
|
||||
return handleOpenInApps();
|
||||
}
|
||||
|
||||
// API: Open a file in an app. Resolves the repo-relative `git diff`
|
||||
// path against the VCS root server-side (resolveAgentCwd folds in
|
||||
// workspace.root, the PR local checkout, resolveVcsCwd(gitContext.cwd),
|
||||
// and process.cwd()) — not the client `base`, which is wrong when
|
||||
// review runs from a subdirectory — then containment-checks it.
|
||||
if (url.pathname === "/api/open-in" && req.method === "POST") {
|
||||
return handleOpenIn(req, { resolveRoot: resolveOpenInRoot });
|
||||
}
|
||||
|
||||
// API: cheap staleness probe — has the underlying VCS state changed
|
||||
// since the current diff snapshot was computed? Best-effort: anything
|
||||
// that cannot be fingerprinted reports fresh (no banner).
|
||||
if (url.pathname === "/api/diff/fresh" && req.method === "GET") {
|
||||
// In PR review the local checkout can appear (pool warmup) or change
|
||||
// (in-place PR switch) after the initial /api/diff, so re-advertise it
|
||||
// on every probe — the Open-in control tracks the current checkout
|
||||
// without a page reload. resolvePRLocalCwd() is null until a usable
|
||||
// checkout exists. Non-PR sessions never carry this field.
|
||||
const prCwdAdvert = isPRMode ? { agentCwd: resolvePRLocalCwd() ?? null } : {};
|
||||
const baseline = currentFingerprint;
|
||||
if (baseline == null) return Response.json({ fresh: true });
|
||||
if (baseline == null) return Response.json({ fresh: true, ...prCwdAdvert });
|
||||
const probe = await computeDiffFingerprint();
|
||||
// A diff switch landing mid-probe replaces the snapshot (and its
|
||||
// fingerprint); report fresh and let the next poll compare
|
||||
// against the new baseline.
|
||||
if (currentFingerprint !== baseline) return Response.json({ fresh: true });
|
||||
if (currentFingerprint !== baseline) return Response.json({ fresh: true, ...prCwdAdvert });
|
||||
const fresh = probe == null || probe === baseline;
|
||||
// The probe fingerprint lets the client distinguish "still the
|
||||
// same staleness I dismissed" from "ANOTHER change landed since".
|
||||
return Response.json({ fresh, ...(fresh ? {} : { fingerprint: probe }) });
|
||||
return Response.json({ fresh, ...(fresh ? {} : { fingerprint: probe }), ...prCwdAdvert });
|
||||
}
|
||||
|
||||
// API: Get semantic diff content
|
||||
@@ -1129,6 +1168,9 @@ export async function startReviewServer(
|
||||
rawPatch: currentPatch,
|
||||
gitRef: currentGitRef,
|
||||
prMetadata: pr.metadata,
|
||||
// The new PR's checkout (null while warming) so Open-in re-roots
|
||||
// immediately on switch instead of waiting for the 5s probe.
|
||||
agentCwd: resolvePRLocalCwd(pr.metadata) ?? null,
|
||||
prStackInfo,
|
||||
prStackTree,
|
||||
prDiffScope: currentPRDiffScope,
|
||||
|
||||
@@ -82,3 +82,49 @@ export function isWithinDirectory(filePath: string, root: string): boolean {
|
||||
const rel = relative(resolvedRoot, resolved);
|
||||
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute file an /api/open-in request may launch and confirm it
|
||||
* stays within an allowed root — the security boundary for the open-in
|
||||
* endpoints, shared by the Bun and Pi servers so the resolve + containment
|
||||
* can't drift between runtimes (same reason isWithinDirectory is single-sourced).
|
||||
*
|
||||
* `resolveRoot` may return one root or several: annotate scopes opens to the
|
||||
* same set of reference roots `/api/doc` serves from, so any linked doc the
|
||||
* user can view can also be opened. Root precedence: server-supplied root(s)
|
||||
* override the client `base`; then `base`; then an absolute `filePath`'s own
|
||||
* directory; then cwd. Each root resolves `filePath` independently, so a
|
||||
* relative path is resolved correctly per-root (not only against the first).
|
||||
* Returns the absolute path, or null when it escapes every allowed root.
|
||||
*/
|
||||
export function resolveOpenInTarget(
|
||||
filePath: string,
|
||||
base: string | null,
|
||||
resolveRoot?: () => string | string[],
|
||||
): string | null {
|
||||
const provided = resolveRoot?.();
|
||||
const roots = (
|
||||
provided == null
|
||||
? [
|
||||
base
|
||||
? resolvePath(base)
|
||||
: isAbsolute(filePath)
|
||||
? dirname(resolvePath(filePath))
|
||||
: resolvePath(process.cwd()),
|
||||
]
|
||||
: Array.isArray(provided)
|
||||
? provided
|
||||
: [provided]
|
||||
)
|
||||
.filter((r): r is string => !!r)
|
||||
.map((r) => resolvePath(r));
|
||||
// Resolve against each root and return the first that contains its own
|
||||
// resolution. For an absolute filePath the resolution is identical for every
|
||||
// root (a pure containment check); for a relative filePath this avoids the
|
||||
// duplicate-basename trap of resolving only against roots[0].
|
||||
for (const root of roots) {
|
||||
const abs = resolvePath(root, filePath);
|
||||
if (isWithinDirectory(abs, root)) return abs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Open-in-App Catalog — single source of truth.
|
||||
*
|
||||
* Shared between the Bun/Pi servers (which launch the app) and the UI (which
|
||||
* renders the picker). Runtime-agnostic: no Bun or Node-specific APIs, pure
|
||||
* data + types only.
|
||||
*
|
||||
* Mirrors OpenCode's "Open in" app list. Each entry declares how to launch the
|
||||
* app per platform:
|
||||
* - mac.appName -> `open -a "<appName>" <target>`
|
||||
* - win.bin -> `<bin> <target>` (resolved against PATH)
|
||||
* - linux.bin -> `<bin> <target>`
|
||||
*
|
||||
* `kind` drives launch semantics:
|
||||
* - file-manager -> reveal the file (mac: `open -R`, win: `explorer /select,`,
|
||||
* linux: open the parent dir)
|
||||
* - editor -> open the file itself
|
||||
* - terminal -> open the file's parent directory
|
||||
*
|
||||
* One special id has no platform launch fields:
|
||||
* - 'reveal' (kind file-manager) — uses the OS file manager
|
||||
*/
|
||||
|
||||
export type OpenInKind = 'file-manager' | 'editor' | 'terminal';
|
||||
|
||||
export interface OpenInApp {
|
||||
/** Stable identifier persisted in the cookie + sent to the server. */
|
||||
id: string;
|
||||
/** Human-readable label. For 'reveal' this is resolved per-platform. */
|
||||
label: string;
|
||||
kind: OpenInKind;
|
||||
/** Icon id understood by AppIcon. For 'reveal' this is resolved per-platform. */
|
||||
icon: string;
|
||||
/** macOS application bundle/display name passed to `open -a`. */
|
||||
mac?: { appName: string };
|
||||
/** Windows PATH binary. */
|
||||
win?: { bin: string };
|
||||
/** Linux PATH binary. */
|
||||
linux?: { bin: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog, in menu order. The UI groups by `kind`
|
||||
* (file-manager + default first, then editors, then terminals).
|
||||
*/
|
||||
export const OPEN_IN_APPS: OpenInApp[] = [
|
||||
// ── File manager (always available) ────────────────────────────────────
|
||||
{
|
||||
id: 'reveal',
|
||||
label: 'Finder', // resolved per-platform, see resolveRevealLabel
|
||||
kind: 'file-manager',
|
||||
icon: 'finder', // resolved per-platform, see resolveRevealIcon
|
||||
},
|
||||
|
||||
// ── Editors ────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'VS Code',
|
||||
kind: 'editor',
|
||||
icon: 'vscode',
|
||||
mac: { appName: 'Visual Studio Code' },
|
||||
win: { bin: 'code' },
|
||||
linux: { bin: 'code' },
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
kind: 'editor',
|
||||
icon: 'cursor',
|
||||
mac: { appName: 'Cursor' },
|
||||
win: { bin: 'cursor' },
|
||||
linux: { bin: 'cursor' },
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
label: 'Zed',
|
||||
kind: 'editor',
|
||||
icon: 'zed',
|
||||
mac: { appName: 'Zed' },
|
||||
win: { bin: 'zed' },
|
||||
linux: { bin: 'zed' },
|
||||
},
|
||||
{
|
||||
id: 'sublime-text',
|
||||
label: 'Sublime Text',
|
||||
kind: 'editor',
|
||||
icon: 'sublime-text',
|
||||
mac: { appName: 'Sublime Text' },
|
||||
win: { bin: 'subl' },
|
||||
linux: { bin: 'subl' },
|
||||
},
|
||||
{
|
||||
id: 'textmate',
|
||||
label: 'TextMate',
|
||||
kind: 'editor',
|
||||
icon: 'textmate',
|
||||
mac: { appName: 'TextMate' },
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
kind: 'editor',
|
||||
icon: 'antigravity',
|
||||
mac: { appName: 'Antigravity' },
|
||||
},
|
||||
{
|
||||
id: 'xcode',
|
||||
label: 'Xcode',
|
||||
kind: 'editor',
|
||||
icon: 'xcode',
|
||||
mac: { appName: 'Xcode' },
|
||||
},
|
||||
{
|
||||
id: 'android-studio',
|
||||
label: 'Android Studio',
|
||||
kind: 'editor',
|
||||
icon: 'android-studio',
|
||||
mac: { appName: 'Android Studio' },
|
||||
},
|
||||
|
||||
// ── Terminals ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'terminal',
|
||||
label: 'Terminal',
|
||||
kind: 'terminal',
|
||||
icon: 'terminal',
|
||||
mac: { appName: 'Terminal' },
|
||||
},
|
||||
{
|
||||
id: 'iterm2',
|
||||
label: 'iTerm2',
|
||||
kind: 'terminal',
|
||||
icon: 'iterm2',
|
||||
mac: { appName: 'iTerm' }, // bundle name is "iTerm", not "iTerm2"
|
||||
},
|
||||
{
|
||||
id: 'ghostty',
|
||||
label: 'Ghostty',
|
||||
kind: 'terminal',
|
||||
icon: 'ghostty',
|
||||
mac: { appName: 'Ghostty' },
|
||||
},
|
||||
{
|
||||
id: 'warp',
|
||||
label: 'Warp',
|
||||
kind: 'terminal',
|
||||
icon: 'warp',
|
||||
mac: { appName: 'Warp' },
|
||||
},
|
||||
{
|
||||
id: 'powershell',
|
||||
label: 'PowerShell',
|
||||
kind: 'terminal',
|
||||
icon: 'powershell',
|
||||
win: { bin: 'powershell' },
|
||||
},
|
||||
];
|
||||
|
||||
export type OpenInPlatform = 'mac' | 'win' | 'linux';
|
||||
|
||||
/**
|
||||
* Per-platform label for the 'reveal' (file-manager) entry.
|
||||
*/
|
||||
export function resolveRevealLabel(platform: OpenInPlatform): string {
|
||||
switch (platform) {
|
||||
case 'win':
|
||||
return 'Explorer';
|
||||
case 'linux':
|
||||
return 'Files';
|
||||
case 'mac':
|
||||
default:
|
||||
return 'Finder';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-platform icon for the 'reveal' (file-manager) entry:
|
||||
* finder on mac/linux, file-explorer on win.
|
||||
*/
|
||||
export function resolveRevealIcon(platform: OpenInPlatform): string {
|
||||
return platform === 'win' ? 'file-explorer' : 'finder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a catalog entry by id.
|
||||
*/
|
||||
export function getOpenInApp(id: string): OpenInApp | undefined {
|
||||
return OPEN_IN_APPS.find((app) => app.id === id);
|
||||
}
|
||||
@@ -53,7 +53,8 @@
|
||||
"./source-save": "./source-save.ts",
|
||||
"./source-save-node": "./source-save-node.ts",
|
||||
"./browser-paths": "./browser-paths.ts",
|
||||
"./workspace-status": "./workspace-status.ts"
|
||||
"./workspace-status": "./workspace-status.ts",
|
||||
"./open-in-apps": "./open-in-apps.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.64",
|
||||
|
||||
@@ -14,6 +14,7 @@ import React from 'react';
|
||||
import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
|
||||
import type { PlanDiffStats } from '../utils/planDiffEngine';
|
||||
import { hostnameOrFallback } from '@plannotator/shared/project';
|
||||
import { OpenInAppButton } from './OpenInAppButton';
|
||||
|
||||
export interface LinkedDocBadgeInfo {
|
||||
filepath: string;
|
||||
@@ -35,6 +36,11 @@ export interface DocBadgesProps {
|
||||
linkedDocInfo?: LinkedDocBadgeInfo | null;
|
||||
/** Source attribution for HTML/URL annotations (e.g. "https://..." or "index.html") */
|
||||
sourceInfo?: string;
|
||||
/**
|
||||
* Absolute on-disk path of the annotated source file, used by the
|
||||
* Open-in-app control. Omitted (or an https:// URL) -> no control rendered.
|
||||
*/
|
||||
openInAppPath?: string | null;
|
||||
}
|
||||
|
||||
export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
@@ -48,15 +54,21 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
archiveInfo,
|
||||
linkedDocInfo,
|
||||
sourceInfo,
|
||||
openInAppPath,
|
||||
}) => {
|
||||
const isRow = layout === 'row';
|
||||
const canOpenInApp =
|
||||
!!openInAppPath && !/^https?:\/\//i.test(openInAppPath);
|
||||
const openInButton = canOpenInApp ? (
|
||||
<OpenInAppButton filePath={openInAppPath} base={null} />
|
||||
) : null;
|
||||
|
||||
// In row layout, only PlanDiffBadge (when it has stats to show) and
|
||||
// archiveInfo actually render — everything else is hidden. Check what
|
||||
// will truly produce visible output to avoid an empty wrapper div.
|
||||
const anything = isRow
|
||||
? (!linkedDocInfo && ((hasPreviousVersion && planDiffStats) || archiveInfo))
|
||||
: repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo;
|
||||
: repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || canOpenInApp;
|
||||
if (!anything) return null;
|
||||
|
||||
// Row layout: single horizontal line. Column layout: stacked rows.
|
||||
@@ -66,6 +78,10 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
|
||||
return (
|
||||
<div className={outerClass}>
|
||||
{/* Open-in-app normally renders inline (to the right) within the source /
|
||||
linked-doc rows below. This standalone fallback only fires when there
|
||||
is no file row to attach to. Hidden in the sticky row and for URLs. */}
|
||||
{!isRow && canOpenInApp && !sourceInfo && !linkedDocInfo && openInButton}
|
||||
{/* Row layout (sticky lane) omits repo/branch to keep the bar compact —
|
||||
they'd otherwise push the container wide enough to visually extend
|
||||
under the action buttons. Plan-diff badge still renders below. */}
|
||||
@@ -92,14 +108,17 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
)}
|
||||
|
||||
{sourceInfo && !linkedDocInfo && !isRow && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 bg-muted/30 rounded truncate max-w-[200px]"
|
||||
title={sourceInfo}
|
||||
>
|
||||
{/^https?:\/\//i.test(sourceInfo)
|
||||
? hostnameOrFallback(sourceInfo)
|
||||
: sourceInfo}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 bg-muted/30 rounded truncate max-w-[200px]"
|
||||
title={sourceInfo}
|
||||
>
|
||||
{/^https?:\/\//i.test(sourceInfo)
|
||||
? hostnameOrFallback(sourceInfo)
|
||||
: sourceInfo}
|
||||
</span>
|
||||
{openInButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onPlanDiffToggle && !linkedDocInfo && (
|
||||
@@ -171,6 +190,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
>
|
||||
{linkedDocInfo.filepath.split('/').pop()}
|
||||
</span>
|
||||
{openInButton}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -202,6 +222,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
||||
>
|
||||
{linkedDocInfo.filepath.split('/').pop()}
|
||||
</span>
|
||||
{openInButton}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react';
|
||||
import { AppIcon } from './icons/AppIcon';
|
||||
import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage';
|
||||
import type { OpenInKind } from '@plannotator/shared/open-in-apps';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
/**
|
||||
* OpenInAppButton — split button + file-actions menu.
|
||||
*
|
||||
* When the file is launchable (local session, resolvable path), the left half
|
||||
* opens it in the last-selected app and the chevron drops down the detected
|
||||
* apps (file manager, then editors, then terminals) — picking one persists it
|
||||
* AND opens immediately. The menu also carries Copy path and (review only)
|
||||
* Copy file diff.
|
||||
*
|
||||
* When launching is unavailable (remote session, or PR review with no local
|
||||
* checkout), the app actions are hidden but the copy actions remain behind a
|
||||
* `⋯` overflow button — so e.g. Copy file diff never disappears. Renders
|
||||
* nothing when there is neither an app to open nor a diff to copy.
|
||||
*/
|
||||
|
||||
interface DetectedApp {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: OpenInKind;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface OpenInAppButtonProps {
|
||||
filePath: string | null | undefined;
|
||||
base?: string | null;
|
||||
/** Diff/patch text for the "Copy file diff" menu action (review only). */
|
||||
diffText?: string | null;
|
||||
/**
|
||||
* When false, hide the app-launch actions (e.g. PR review with no local
|
||||
* checkout, where files aren't resolvable on disk) but keep copy actions.
|
||||
*/
|
||||
canOpen?: boolean;
|
||||
/** When true, render the primary label text alongside the icon. */
|
||||
showLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// The host app catalog is static for the session, but the all-files view
|
||||
// renders one OpenInAppButton per file — so fetch /api/open-in/apps once and
|
||||
// share the promise across every instance instead of N identical requests.
|
||||
interface OpenInAppsResponse {
|
||||
available: boolean;
|
||||
apps: DetectedApp[];
|
||||
}
|
||||
let openInAppsPromise: Promise<OpenInAppsResponse> | null = null;
|
||||
function loadOpenInApps(): Promise<OpenInAppsResponse> {
|
||||
if (!openInAppsPromise) {
|
||||
openInAppsPromise = fetch('/api/open-in/apps')
|
||||
.then((r) => r.json())
|
||||
.then((data: OpenInAppsResponse) => ({
|
||||
available: !!data.available,
|
||||
apps: Array.isArray(data.apps) ? data.apps : [],
|
||||
}))
|
||||
.catch(() => {
|
||||
openInAppsPromise = null; // don't memoize failure — let the next mount retry
|
||||
return { available: false, apps: [] };
|
||||
});
|
||||
}
|
||||
return openInAppsPromise;
|
||||
}
|
||||
|
||||
export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
|
||||
filePath,
|
||||
base,
|
||||
diffText,
|
||||
canOpen = true,
|
||||
showLabel = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [apps, setApps] = useState<DetectedApp[] | null>(null);
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [currentId, setCurrentId] = useState<string>(() => getLastOpenInApp());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOpenInApps().then((data) => {
|
||||
if (cancelled) return;
|
||||
setAvailable(data.available);
|
||||
setApps(data.apps);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (errorTimer.current) clearTimeout(errorTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const list = apps ?? [];
|
||||
// Apps are launchable: server says available, the surface allows it, the path
|
||||
// is resolvable, and at least one app was detected.
|
||||
const openable =
|
||||
available === true && canOpen && !!filePath && list.length > 0;
|
||||
|
||||
// Nothing to do: can't open AND nothing to copy. Also preserve the
|
||||
// "hide when unavailable" behavior for surfaces whose only copy action is the
|
||||
// path (annotate in a remote session) — show the standalone menu only when
|
||||
// there's a diff to copy.
|
||||
if (!openable && !diffText) return null;
|
||||
|
||||
// Resolve the active app: last-used if still detected, else reveal, else first.
|
||||
const resolvedId = list.some((a) => a.id === currentId)
|
||||
? currentId
|
||||
: list.some((a) => a.id === 'reveal')
|
||||
? 'reveal'
|
||||
: (list[0]?.id ?? 'reveal');
|
||||
const currentApp = list.find((a) => a.id === resolvedId) ?? list[0];
|
||||
|
||||
const flashError = (msg: string) => {
|
||||
setError(msg);
|
||||
if (errorTimer.current) clearTimeout(errorTimer.current);
|
||||
errorTimer.current = setTimeout(() => setError(null), 4000);
|
||||
};
|
||||
|
||||
const open = async (appId: string) => {
|
||||
if (!filePath || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/open-in', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filePath, base: base ?? null, appId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { ok: boolean; error?: string }
|
||||
| null;
|
||||
if (!res.ok || !data || data.ok === false) {
|
||||
flashError(data?.error || 'Failed to open');
|
||||
}
|
||||
} catch {
|
||||
flashError('Failed to open');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectApp = (appId: string) => {
|
||||
setLastOpenInApp(appId);
|
||||
setCurrentId(appId);
|
||||
setMenuOpen(false);
|
||||
void open(appId);
|
||||
};
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
// Menu groups: file manager first, then editors, then terminals.
|
||||
const grouped = [
|
||||
list.filter((a) => a.kind === 'file-manager'),
|
||||
list.filter((a) => a.kind === 'editor'),
|
||||
list.filter((a) => a.kind === 'terminal'),
|
||||
].filter((g) => g.length > 0);
|
||||
|
||||
const Spinner = (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const isDisabled = disabled || busy;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center rounded overflow-hidden">
|
||||
{/* Primary: open in the current app (only when launchable). */}
|
||||
{openable && currentApp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(resolvedId)}
|
||||
disabled={isDisabled}
|
||||
className={`text-xs flex items-center gap-1 py-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
showLabel ? 'pl-2 pr-1.5' : 'px-1.5'
|
||||
}`}
|
||||
title={error ?? currentApp.label}
|
||||
aria-label={`Open in ${currentApp.label}`}
|
||||
>
|
||||
{busy ? Spinner : <AppIcon id={currentApp.icon} className="w-3.5 h-3.5" />}
|
||||
{showLabel && <span className="whitespace-nowrap">{currentApp.label}</span>}
|
||||
</button>
|
||||
)}
|
||||
{/* Chevron (with primary) or standalone overflow (copy-only). */}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={`text-xs flex items-center py-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
openable ? 'px-1 border-l border-border/50' : 'px-1.5'
|
||||
}`}
|
||||
title={openable ? 'Open in…' : 'File actions'}
|
||||
aria-label={openable ? 'Choose app to open in' : 'File actions'}
|
||||
>
|
||||
{openable ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="min-w-[12rem]"
|
||||
// Don't snap focus (and its focus ring) back onto the trigger when
|
||||
// the menu closes — that left-edge bar reads as a stray artifact.
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{openable &&
|
||||
grouped.map((group, gi) => (
|
||||
<React.Fragment key={group[0].kind}>
|
||||
{gi > 0 && <DropdownMenuSeparator />}
|
||||
{group.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
selectApp(app.id);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<AppIcon id={app.icon} className="w-4 h-4" />
|
||||
<span className="flex-1 truncate">{app.label}</span>
|
||||
{app.id === resolvedId && <Check className="w-3.5 h-3.5 text-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{openable && (filePath || diffText) && <DropdownMenuSeparator />}
|
||||
{filePath && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
void copyText(filePath);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="flex-1">Copy path</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{diffText && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
void copyText(diffText);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="flex-1">Copy file diff</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{error && (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs text-destructive truncate max-w-[12rem]"
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -96,6 +96,8 @@ interface ViewerProps {
|
||||
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
|
||||
/** Source attribution for HTML/URL annotations (e.g. URL or filename) */
|
||||
sourceInfo?: string;
|
||||
/** Absolute path of the annotated source file for the Open-in-app control. */
|
||||
openInAppPath?: string | null;
|
||||
/**
|
||||
* Message picker affordance — annotate-last mode only. Shown as a button in
|
||||
* the sticky-top action bar so the user can switch to a different recent
|
||||
@@ -180,6 +182,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
actionsLabelMode = 'full',
|
||||
archiveInfo,
|
||||
sourceInfo,
|
||||
openInAppPath,
|
||||
messagePickerInfo,
|
||||
onToggleCheckbox,
|
||||
checkboxOverrides,
|
||||
@@ -539,7 +542,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
style={{ WebkitTouchCallout: 'none' } as React.CSSProperties}
|
||||
>
|
||||
{/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
|
||||
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo) && (
|
||||
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
|
||||
<div data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
|
||||
<DocBadges
|
||||
layout="column"
|
||||
@@ -552,6 +555,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
archiveInfo={archiveInfo}
|
||||
linkedDocInfo={linkedDocInfo}
|
||||
sourceInfo={sourceInfo}
|
||||
openInAppPath={openInAppPath}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_5436_78784" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="2" width="36" height="36">
|
||||
<path d="M36.5936 2H3.4064C2.62967 2 2 2.62967 2 3.4064V36.5936C2 37.3703 2.62967 38 3.4064 38H36.5936C37.3703 38 38 37.3703 38 36.5936V3.4064C38 2.62967 37.3703 2 36.5936 2Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5436_78784)">
|
||||
<path opacity="0.2" d="M15.7562 37.8478C15.1622 37.7074 14.666 37.378 13.9706 36.6652C12.8096 35.4736 11.867 34.978 10.229 34.696C8.53043 34.4032 7.77503 33.772 7.32143 32.2636C6.76103 30.4012 6.17543 29.5096 4.88843 28.5592C3.42863 27.481 3.05423 26.5306 3.41063 24.8074C3.74063 23.2114 3.61463 22.0774 2.94383 20.6182C2.14463 18.8782 2.24303 17.9482 3.37883 16.522C4.41983 15.214 4.74683 14.35 4.90283 12.4924C5.06003 10.6174 5.58203 9.84819 7.11803 9.22539C8.40083 8.70579 9.49763 7.80339 10.106 6.76899C10.8092 5.57199 10.9118 5.43519 11.3474 5.10999C12.0506 4.58559 12.6194 4.43979 13.7018 4.50759C15.5828 4.62579 16.5908 4.39959 17.8538 3.57759C19.5008 2.50539 20.4992 2.50539 22.1462 3.57759C23.4092 4.39959 24.4172 4.62579 26.2982 4.50759C28.0766 4.39659 28.8164 4.86339 29.9006 6.78099C30.4382 7.73139 31.5362 8.65599 32.7386 9.17019C34.4498 9.90279 34.9478 10.5718 35.0738 12.3094C35.2106 14.191 35.5964 15.2434 36.6188 16.5214C37.757 17.9452 37.856 18.8764 37.0556 20.6182C36.3854 22.0768 36.2594 23.2114 36.5894 24.8074C36.9452 26.5306 36.5714 27.481 35.111 28.5592C33.8246 29.5096 33.239 30.4012 32.6786 32.2636C32.2244 33.772 31.4696 34.4032 29.7704 34.696C28.1294 34.978 27.1856 35.476 26.0294 36.6688C24.7472 37.9912 23.8274 38.209 22.0352 37.6168C20.5694 37.132 19.4402 37.1302 17.9798 37.609C16.9574 37.945 16.412 38.0032 15.7562 37.8478Z" fill="black"/>
|
||||
<path d="M15.7562 37.4962C15.1622 37.3558 14.666 37.0264 13.9706 36.3136C12.8096 35.122 11.867 34.6264 10.229 34.3444C8.53043 34.0516 7.77503 33.4204 7.32143 31.912C6.76103 30.0496 6.17543 29.158 4.88843 28.2076C3.42863 27.1294 3.05423 26.179 3.41063 24.4558C3.74063 22.8598 3.61463 21.7258 2.94383 20.2666C2.14463 18.5266 2.24303 17.5966 3.37883 16.1704C4.41983 14.8624 4.74683 13.9984 4.90283 12.1408C5.06003 10.2658 5.58203 9.49662 7.11803 8.87382C8.40083 8.35422 9.49763 7.45182 10.106 6.41742C10.8092 5.22042 10.9118 5.08362 11.3474 4.75842C12.0506 4.23402 12.6194 4.08822 13.7018 4.15602C15.5828 4.27422 16.5908 4.04802 17.8538 3.22602C19.5008 2.15383 20.4992 2.15383 22.1462 3.22602C23.4092 4.04802 24.4172 4.27422 26.2982 4.15602C28.0766 4.04502 28.8164 4.51182 29.9006 6.42942C30.4382 7.37982 31.5362 8.30442 32.7386 8.81862C34.4498 9.55122 34.9478 10.2202 35.0738 11.9578C35.2106 13.8394 35.5964 14.8918 36.6188 16.1698C37.757 17.5936 37.856 18.5248 37.0556 20.2666C36.3854 21.7258 36.2594 22.8604 36.5894 24.4558C36.9452 26.179 36.5714 27.1294 35.111 28.2076C33.8246 29.158 33.239 30.0496 32.6786 31.912C32.2244 33.4204 31.4696 34.0516 29.7704 34.3444C28.1294 34.6264 27.1856 35.1244 26.0294 36.3172C24.7472 37.6396 23.8274 37.8574 22.0352 37.2652C20.5694 36.7804 19.4402 36.7786 17.9798 37.2574C16.9574 37.5934 16.412 37.6516 15.7562 37.4962Z" fill="white"/>
|
||||
<path d="M28.0543 30.5054C28.0876 30.6095 28.0808 30.7223 28.0352 30.8217C27.9896 30.9211 27.9086 30.9999 27.8079 31.0426C27.7073 31.0854 27.5943 31.089 27.4912 31.0527C27.388 31.0165 27.3021 30.9431 27.2503 30.8468L26.1937 28.8848C26.1508 28.8057 26.125 28.7185 26.1178 28.6288C26.1106 28.5392 26.1222 28.449 26.1519 28.3641C26.1816 28.2792 26.2287 28.2014 26.2902 28.1358C26.3517 28.0702 26.4263 28.0181 26.5091 27.983C26.5919 27.9478 26.6811 27.9304 26.7711 27.9317C26.861 27.9331 26.9497 27.9532 27.0314 27.9908C27.1131 28.0285 27.186 28.0827 27.2455 28.1502C27.305 28.2177 27.3497 28.2968 27.3769 28.3826L28.0543 30.5054Z" fill="#034ECA"/>
|
||||
<path d="M28.0543 30.5054C28.0876 30.6095 28.0808 30.7223 28.0352 30.8217C27.9896 30.9211 27.9086 30.9999 27.8079 31.0426C27.7073 31.0854 27.5943 31.089 27.4912 31.0527C27.388 31.0165 27.3021 30.9431 27.2503 30.8468L26.1937 28.8848C26.1508 28.8057 26.125 28.7185 26.1178 28.6288C26.1106 28.5392 26.1222 28.449 26.1519 28.3641C26.1816 28.2792 26.2287 28.2014 26.2902 28.1358C26.3517 28.0702 26.4263 28.0181 26.5091 27.983C26.5919 27.9478 26.6811 27.9304 26.7711 27.9317C26.861 27.9331 26.9497 27.9532 27.0314 27.9908C27.1131 28.0285 27.186 28.0827 27.2455 28.1502C27.305 28.2177 27.3497 28.2968 27.3769 28.3826L28.0543 30.5054Z" fill="url(#paint0_linear_5436_78784)"/>
|
||||
<mask id="mask1_5436_78784" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="24" y="16" width="7" height="8">
|
||||
<path d="M27.2289 23.4656C27.6264 23.2061 27.9706 22.8729 28.2429 22.484C28.5285 22.0786 28.7308 21.6206 28.8381 21.1364L28.8459 21.1376L29.2899 21.2216C29.5379 21.2683 29.7859 21.3149 30.0339 21.3614C30.0861 21.3707 30.1397 21.368 30.1907 21.3535C30.2416 21.3389 30.2886 21.3129 30.3279 21.2774C30.3896 21.2221 30.4289 21.1461 30.4385 21.0638C30.4481 20.9815 30.4274 20.8985 30.3801 20.8304C30.3548 20.7933 30.3225 20.7616 30.2849 20.737C30.2473 20.7123 30.2053 20.6954 30.1611 20.687C30.0574 20.6676 29.9536 20.6482 29.8497 20.6288L29.4171 20.5472L28.9731 20.4632L28.9251 20.4548L28.9263 20.4176C28.9374 19.9613 28.8648 19.5068 28.7121 19.0766L28.6989 19.0406C28.544 18.6143 28.3128 18.2197 28.0167 17.876C28.0269 17.864 28.0365 17.852 28.0467 17.8412L28.3395 17.498L28.6251 17.1632L28.8303 16.9226C28.889 16.8533 28.9181 16.7636 28.9113 16.673C28.9079 16.6337 28.898 16.5951 28.8819 16.559C28.853 16.4945 28.8047 16.4406 28.7438 16.4047C28.6828 16.3688 28.6122 16.3527 28.5417 16.3586C28.4511 16.3657 28.367 16.4085 28.3077 16.4774L28.1025 16.718L27.8169 17.0534L27.5241 17.396L27.5211 17.399C26.8851 16.89 26.1015 16.6002 25.2873 16.5728C24.9589 16.5625 24.6305 16.5953 24.3105 16.6706L26.7969 23.708C26.9451 23.636 27.0903 23.5556 27.2289 23.4656Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_5436_78784)">
|
||||
<path d="M31.0238 22.2636L28.3718 14.748L24.1436 16.2402L26.7956 23.7552L31.0238 22.2636Z" fill="#4FAF53"/>
|
||||
<g opacity="0.8" filter="url(#filter0_f_5436_78784)">
|
||||
<path d="M28.5021 19.4415C28.2393 20.3715 27.4419 18.2955 26.0079 17.8911C24.5739 17.4861 22.8093 18.8391 23.0715 17.9091C23.5395 17.2263 24.7947 16.7049 26.2287 17.1099C27.6627 17.5143 28.5147 18.7263 28.5021 19.4415Z" fill="url(#paint1_linear_5436_78784)" fill-opacity="0.3"/>
|
||||
</g>
|
||||
<g opacity="0.7" filter="url(#filter1_f_5436_78784)">
|
||||
<path d="M28.3214 18.9303C27.533 18.3705 28.2152 20.4873 27.353 21.7023C26.4902 22.9173 24.2672 22.9719 25.055 23.5317C25.8482 23.7687 27.1526 23.3877 28.0148 22.1727C28.877 20.9577 28.7804 19.4787 28.3214 18.9303Z" fill="url(#paint2_linear_5436_78784)" fill-opacity="0.4"/>
|
||||
</g>
|
||||
<g opacity="0.6" filter="url(#filter2_f_5436_78784)">
|
||||
<path d="M25.2567 16.2322C24.2559 16.2634 23.4471 16.3834 23.4507 16.4992C23.4543 16.6156 24.2691 16.684 25.2699 16.6522C26.2707 16.621 27.6687 17.455 27.6651 17.3386C27.6615 17.2228 26.2575 16.2004 25.2567 16.2322Z" fill="#8BD8A0"/>
|
||||
</g>
|
||||
<g opacity="0.5" filter="url(#filter3_f_5436_78784)">
|
||||
<path d="M27.7943 23.4206C26.9945 24.0242 26.2901 24.4382 26.2199 24.3458C26.1503 24.2528 26.7413 23.6888 27.5399 23.0846C28.3391 22.481 28.9043 20.9546 28.9739 21.047C29.0441 21.14 28.5929 22.817 27.7937 23.4206H27.7943Z" fill="#8BD8A0"/>
|
||||
</g>
|
||||
<g opacity="0.7" filter="url(#filter4_f_5436_78784)">
|
||||
<path d="M28.1634 16.818C27.7974 17.2656 27.6198 17.4678 27.5802 17.436C27.5406 17.403 27.6546 17.148 28.02 16.701C28.245 16.14 28.914 16.413 28.9536 16.4454C28.9932 16.4778 28.6314 16.1796 28.1634 16.818Z" fill="#0D652D"/>
|
||||
</g>
|
||||
<g opacity="0.1" filter="url(#filter5_f_5436_78784)">
|
||||
<path d="M27.5104 17.3978C27.5446 17.4236 27.8152 17.1272 28.1146 16.7348C28.4146 16.343 28.6294 16.004 28.5952 15.9782C28.561 15.9518 28.2904 16.2482 27.991 16.6406C27.691 17.033 27.4762 17.3714 27.5104 17.3978Z" fill="black"/>
|
||||
</g>
|
||||
<g opacity="0.3" filter="url(#filter6_f_5436_78784)">
|
||||
<path d="M28.5244 17.0885C28.1422 17.5565 27.97 17.7797 28.0018 17.8055C28.0336 17.8313 28.2568 17.6501 28.639 17.1815C29.1886 16.6667 28.6186 16.2881 28.5658 16.3643C28.4098 16.5305 29.0728 16.4687 28.5244 17.0885Z" fill="white"/>
|
||||
</g>
|
||||
<path opacity="0.15" d="M28.3493 16.054C28.1837 16.1728 28.1621 16.4272 28.3013 16.6222C28.4417 16.8178 28.6889 16.8802 28.8551 16.7608C29.0207 16.642 29.0423 16.3876 28.9031 16.1926C28.7627 15.9976 28.5155 15.9358 28.3493 16.0546V16.054Z" fill="url(#paint3_radial_5436_78784)" fill-opacity="0.9"/>
|
||||
<path opacity="0.15" d="M29.9893 20.5305C29.8237 20.6493 29.8021 20.9037 29.9419 21.0987C30.0817 21.2943 30.3289 21.3567 30.4951 21.2373C30.6607 21.1191 30.6823 20.8647 30.5431 20.6691C30.4033 20.4741 30.1555 20.4117 29.9893 20.5305Z" fill="url(#paint4_radial_5436_78784)" fill-opacity="0.9"/>
|
||||
<g opacity="0.1" filter="url(#filter7_f_5436_78784)">
|
||||
<path d="M28.7985 21.1274C28.8081 21.0854 29.2065 21.1406 29.6871 21.2498C30.1683 21.359 30.5511 21.482 30.5409 21.5234C30.5313 21.5654 30.1341 21.5108 29.6529 21.4016C29.1717 21.2918 28.7889 21.1694 28.7985 21.1274Z" fill="black"/>
|
||||
</g>
|
||||
<mask id="mask2_5436_78784" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="28" y="20" width="2" height="2">
|
||||
<path d="M28.8111 21.1823C28.8561 20.9783 28.9005 20.5349 28.9167 20.3387L29.3529 20.3105L29.2989 21.5609C29.1177 21.5195 28.7661 21.3857 28.8111 21.1823Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_5436_78784)">
|
||||
<g style="mix-blend-mode:multiply" opacity="0.3" filter="url(#filter8_f_5436_78784)">
|
||||
<path d="M28.9099 21.2717C28.9315 20.9297 28.9135 20.6495 28.8709 20.6465C28.8277 20.6441 28.8031 20.9075 28.7821 21.2501C28.7611 21.5927 28.7521 21.8855 28.7953 21.8879C28.8379 21.8903 28.8895 21.6149 28.9105 21.2717H28.9099Z" fill="#0D652D"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:screen" opacity="0.3" filter="url(#filter9_f_5436_78784)">
|
||||
<path d="M28.9331 20.5775C28.9451 20.4803 28.9391 20.3993 28.9199 20.3975C28.9007 20.3945 28.8875 20.4695 28.8761 20.5667C28.8647 20.6645 28.8587 20.7479 28.8779 20.7503C28.8971 20.7521 28.9217 20.6753 28.9331 20.5775Z" fill="#81C995"/>
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask3_5436_78784" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="27" y="16" width="2" height="2">
|
||||
<path d="M27.4211 17.3477C27.5861 17.4737 27.9059 17.7857 28.0451 17.9249L28.3961 17.6663L27.5501 16.7441C27.4385 16.8923 27.2555 17.2205 27.4211 17.3477Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_5436_78784)">
|
||||
<g style="mix-blend-mode:multiply" opacity="0.3" filter="url(#filter10_f_5436_78784)">
|
||||
<path d="M27.4388 17.2149C27.6758 17.4639 27.842 17.6889 27.8108 17.7189C27.7802 17.7489 27.5912 17.5629 27.3548 17.3139C27.1184 17.0649 26.9228 16.8471 26.9546 16.8177C26.9858 16.7877 27.2024 16.9659 27.4388 17.2149Z" fill="#0D652D"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:screen" opacity="0.3" filter="url(#filter11_f_5436_78784)">
|
||||
<path d="M27.9035 17.732C27.9749 17.7992 28.0223 17.8652 28.0091 17.879C27.9959 17.8928 27.9383 17.8442 27.8669 17.777C27.7949 17.7098 27.7367 17.6498 27.7499 17.6354C27.7631 17.6216 27.8321 17.6654 27.9035 17.732Z" fill="#81C995"/>
|
||||
</g>
|
||||
</g>
|
||||
<g opacity="0.7" filter="url(#filter12_f_5436_78784)">
|
||||
<path d="M29.6884 21.2036C29.1202 21.098 28.8538 21.0584 28.8442 21.1082C28.8352 21.1586 29.0866 21.2798 29.6542 21.3854C30.1882 21.6686 30.523 21.0284 30.5326 20.9774C30.5422 20.9276 30.4576 21.3896 29.6884 21.2036Z" fill="#0D652D"/>
|
||||
</g>
|
||||
<g opacity="0.3" filter="url(#filter13_f_5436_78784)">
|
||||
<path d="M29.7868 20.7481C29.209 20.6401 28.9408 20.5819 28.9492 20.5393C28.957 20.4973 29.2372 20.4877 29.8156 20.5951C30.7612 20.6167 30.8716 21.2821 30.3796 21.3403C30.1636 21.3253 30.5842 20.8615 29.7868 20.7481Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M26.4056 18.1092C26.3588 18.2244 26.3714 18.3396 26.4356 18.3654C26.4986 18.3912 26.5886 18.3186 26.636 18.2034C26.6828 18.0876 26.6696 17.973 26.606 17.9466C26.543 17.9208 26.453 17.994 26.4056 18.1092Z" fill="url(#paint5_radial_5436_78784)" fill-opacity="0.7"/>
|
||||
<path d="M27.516 21.2687C27.4068 21.2087 27.345 21.1109 27.378 21.0509C27.411 20.9909 27.5268 20.9909 27.636 21.0509C27.7452 21.1109 27.807 21.2087 27.774 21.2687C27.741 21.3287 27.6252 21.3287 27.516 21.2687Z" fill="url(#paint6_radial_5436_78784)" fill-opacity="0.7"/>
|
||||
<g style="mix-blend-mode:multiply" opacity="0.09" filter="url(#filter14_f_5436_78784)">
|
||||
<path d="M26.1009 18.4441C26.4009 18.4573 26.5251 18.2881 26.5491 18.2017C26.7249 17.9161 26.4399 17.7157 26.3067 17.6731C26.1735 17.6299 25.9479 17.5789 25.7337 17.7817C25.5201 17.9845 25.7259 18.4273 26.1009 18.4441Z" fill="#011B04"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:multiply" opacity="0.09" filter="url(#filter15_f_5436_78784)">
|
||||
<path d="M27.0688 21.2009C27.2926 21.0029 27.4948 21.0569 27.5674 21.1085C27.8818 21.2213 27.787 21.5555 27.7108 21.6725C27.6346 21.7895 27.4918 21.9701 27.199 21.9461C26.9068 21.9221 26.7892 21.4481 27.0688 21.2009Z" fill="#011B04"/>
|
||||
</g>
|
||||
<path d="M27.1155 21.8577C27.2835 21.9837 27.5439 21.9219 27.6963 21.7197C27.8487 21.5169 27.8355 21.2505 27.6675 21.1239C27.4995 20.9979 27.2391 21.0597 27.0867 21.2619C26.9343 21.4647 26.9469 21.7311 27.1155 21.8577Z" fill="black"/>
|
||||
<g filter="url(#filter16_d_5436_78784)">
|
||||
<path d="M26.0792 18.3806C26.3252 18.443 26.5652 18.3326 26.6156 18.1328C26.666 17.9336 26.5076 17.7218 26.2616 17.66C26.0156 17.5976 25.7756 17.708 25.7252 17.9078C25.6748 18.107 25.8332 18.3188 26.0792 18.3806Z" fill="black"/>
|
||||
</g>
|
||||
<g filter="url(#filter17_d_5436_78784)">
|
||||
<path d="M27.0923 21.261C27.2441 21.0582 27.5009 20.994 27.6653 21.1176C27.8297 21.2412 27.8393 21.5052 27.6869 21.7074C27.5345 21.9102 27.2777 21.9744 27.1133 21.8514C26.9489 21.7278 26.9399 21.4632 27.0923 21.261Z" fill="black"/>
|
||||
</g>
|
||||
<mask id="mask4_5436_78784" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="25" y="17" width="2" height="2">
|
||||
<path d="M26.0792 18.3809C26.3252 18.4433 26.5652 18.3323 26.6156 18.1331C26.666 17.9339 26.5076 17.7221 26.2616 17.6597C26.0156 17.5979 25.7756 17.7089 25.7252 17.9081C25.6748 18.1073 25.8332 18.3191 26.0792 18.3809Z" fill="#202124"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_5436_78784)">
|
||||
<g filter="url(#filter18_f_5436_78784)">
|
||||
<path d="M26.2887 17.7038C26.0889 17.6318 25.9029 17.6816 25.8315 17.7242C25.9701 17.639 26.1219 17.5982 26.3127 17.669C26.4195 17.7141 26.509 17.7923 26.5677 17.8922C26.5785 17.9102 26.5869 17.9246 26.5917 17.9354L26.5677 17.8922C26.5612 17.882 26.5544 17.872 26.5473 17.8622C26.5113 17.816 26.4435 17.7596 26.2887 17.7038Z" fill="#D8D8D8" fill-opacity="0.29"/>
|
||||
</g>
|
||||
<g opacity="0.8" filter="url(#filter19_f_5436_78784)">
|
||||
<path d="M26.4526 18.2893C26.6536 18.1591 26.6134 17.9719 26.5684 17.8945C26.6248 17.9551 26.6302 18.0979 26.587 18.1741C26.5545 18.2249 26.5078 18.2649 26.4526 18.2893Z" fill="url(#paint7_linear_5436_78784)"/>
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask5_5436_78784" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="26" y="21" width="2" height="1">
|
||||
<path d="M27.0922 21.261C27.2446 21.0582 27.5014 20.994 27.6658 21.1176C27.8302 21.2412 27.8398 21.5052 27.6874 21.7074C27.535 21.9102 27.2782 21.9744 27.1138 21.8514C26.9494 21.7278 26.9398 21.4632 27.0922 21.261Z" fill="#202124"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_5436_78784)">
|
||||
<path d="M27.7161 21.2765C27.6928 21.2133 27.6474 21.1606 27.5883 21.1282C27.5292 21.0958 27.4604 21.0858 27.3945 21.1001C27.4424 21.1052 27.4883 21.1214 27.5288 21.1473C27.5693 21.1732 27.6033 21.2082 27.6279 21.2495C27.6915 21.3605 27.6105 21.5669 27.5619 21.6557L27.6495 21.6917C27.6971 21.635 27.7284 21.5664 27.74 21.4933C27.7517 21.4201 27.7438 21.3452 27.7161 21.2765Z" fill="url(#paint8_linear_5436_78784)"/>
|
||||
<g filter="url(#filter20_f_5436_78784)">
|
||||
<path d="M27.6788 21.6587C27.5684 21.8405 27.3926 21.9185 27.3098 21.9299C27.4718 21.9089 27.6146 21.8459 27.719 21.6713C27.7739 21.569 27.7943 21.4518 27.7772 21.3371C27.7754 21.3209 27.7726 21.3049 27.7688 21.2891C27.7706 21.3029 27.7748 21.3185 27.7772 21.3371L27.7808 21.3731C27.782 21.4319 27.7646 21.5183 27.6788 21.6587Z" fill="#D8D8D8" fill-opacity="0.29"/>
|
||||
</g>
|
||||
</g>
|
||||
<g opacity="0.8" filter="url(#filter21_f_5436_78784)">
|
||||
<path d="M26.408 17.85C26.3666 17.7816 26.27 17.754 26.2268 17.748L26.1848 17.8044C26.2171 17.8098 26.2484 17.8197 26.2778 17.8338C26.3174 17.853 26.3648 17.886 26.384 17.8998L26.408 17.85Z" fill="#E2DCE1"/>
|
||||
</g>
|
||||
<g opacity="0.8" filter="url(#filter22_f_5436_78784)">
|
||||
<path d="M27.6807 21.4713C27.6927 21.5493 27.6375 21.6297 27.6087 21.6609L27.5427 21.6429C27.5637 21.6189 27.5811 21.592 27.5943 21.5631C27.6123 21.5241 27.6273 21.4695 27.6327 21.4473L27.6807 21.4713Z" fill="#E2DCE1"/>
|
||||
</g>
|
||||
<g opacity="0.8" filter="url(#filter23_f_5436_78784)">
|
||||
<path d="M26.1155 18.2938C26.0827 18.2638 26.0511 18.2326 26.0207 18.2002H25.9487C25.9764 18.2367 26.0116 18.2668 26.0519 18.2884L26.1155 18.2938ZM26.4335 18.3058C26.4505 18.2934 26.4664 18.2796 26.4809 18.2644L26.4557 18.2734C26.4374 18.2914 26.4168 18.3069 26.3945 18.3196C26.4005 18.3196 26.4173 18.3166 26.4335 18.3058Z" fill="#E2DCE1"/>
|
||||
</g>
|
||||
<g opacity="0.8" filter="url(#filter24_f_5436_78784)">
|
||||
<path d="M27.1711 21.303C27.1639 21.345 27.1579 21.4092 27.1561 21.4356L27.1003 21.4806C27.0988 21.4349 27.1072 21.3895 27.1249 21.3474L27.1711 21.303ZM27.4111 21.0942C27.4321 21.0931 27.4532 21.0939 27.4741 21.0966L27.4495 21.1056C27.4239 21.103 27.398 21.1038 27.3727 21.108C27.384 21.1001 27.3973 21.0954 27.4111 21.0942Z" fill="#E2DCE1"/>
|
||||
</g>
|
||||
<g opacity="0.3" filter="url(#filter25_f_5436_78784)">
|
||||
<path d="M27.8555 22.3678C26.9435 22.6894 28.1807 20.8414 27.6851 19.4362C27.1895 18.031 25.0667 17.3686 25.9781 17.047C26.8055 17.0362 27.9545 17.761 28.4501 19.1662C28.9463 20.5708 28.4465 21.9658 27.8555 22.3678Z" fill="url(#paint9_linear_5436_78784)" fill-opacity="0.4"/>
|
||||
</g>
|
||||
<g opacity="0.2" filter="url(#filter26_f_5436_78784)">
|
||||
<path d="M27.8555 22.3678C26.9435 22.6894 28.1807 20.8414 27.6851 19.4362C27.1895 18.031 25.0667 17.3686 25.9781 17.047C26.8055 17.0362 27.9545 17.761 28.4501 19.1662C28.9463 20.5708 28.4465 21.9658 27.8555 22.3678Z" fill="url(#paint10_linear_5436_78784)" fill-opacity="0.4"/>
|
||||
</g>
|
||||
<path d="M12.6298 18.5532C12.5306 18.3807 12.3969 18.2305 12.237 18.1121C12.077 17.9937 11.8944 17.9096 11.7004 17.865C11.2913 17.7705 10.8617 17.8339 10.4974 18.0426C10.1272 18.2526 9.84999 18.5904 9.72639 18.9828C9.60279 19.3746 9.67659 19.8486 9.87159 20.193L11.2984 19.4568L12.6298 18.5532Z" fill="#4386F5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.6169 18.5322C14.6809 21.9498 19.0033 23.4126 22.7809 21.7986L24.0421 24.7488C18.7729 27.0006 12.7501 24.9588 9.87012 20.1912L12.6169 18.5322Z" fill="url(#paint11_linear_5436_78784)"/>
|
||||
<path d="M12.0135 30.506C11.9802 30.6102 11.987 30.723 12.0326 30.8224C12.0782 30.9218 12.1592 31.0006 12.2598 31.0433C12.3605 31.086 12.4735 31.0896 12.5766 31.0534C12.6798 31.0172 12.7657 30.9437 12.8175 30.8474L13.8735 28.8854C13.9168 28.8063 13.9431 28.719 13.9506 28.6291C13.9581 28.5393 13.9468 28.4488 13.9172 28.3636C13.8876 28.2784 13.8405 28.2003 13.7789 28.1344C13.7173 28.0686 13.6426 28.0163 13.5596 27.981C13.4766 27.9458 13.3871 27.9283 13.297 27.9298C13.2068 27.9312 13.1179 27.9515 13.0361 27.9894C12.9542 28.0273 12.8812 28.0819 12.8218 28.1498C12.7623 28.2176 12.7177 28.2971 12.6909 28.3832L12.0135 30.506Z" fill="#034ECA"/>
|
||||
<path d="M12.0135 30.506C11.9802 30.6102 11.987 30.723 12.0326 30.8224C12.0782 30.9218 12.1592 31.0006 12.2598 31.0433C12.3605 31.086 12.4735 31.0896 12.5766 31.0534C12.6798 31.0172 12.7657 30.9437 12.8175 30.8474L13.8735 28.8854C13.9168 28.8063 13.9431 28.719 13.9506 28.6291C13.9581 28.5393 13.9468 28.4488 13.9172 28.3636C13.8876 28.2784 13.8405 28.2003 13.7789 28.1344C13.7173 28.0686 13.6426 28.0163 13.5596 27.981C13.4766 27.9458 13.3871 27.9283 13.297 27.9298C13.2068 27.9312 13.1179 27.9515 13.0361 27.9894C12.9542 28.0273 12.8812 28.0819 12.8218 28.1498C12.7623 28.2176 12.7177 28.2971 12.6909 28.3832L12.0135 30.506Z" fill="url(#paint12_linear_5436_78784)"/>
|
||||
<path d="M17.4046 12.0104C17.5693 11.5578 17.9072 11.1891 18.3438 10.9856C18.7804 10.782 19.2799 10.7602 19.7326 10.925C20.1852 11.0898 20.5539 11.4276 20.7574 11.8642C20.961 12.3008 20.9827 12.8004 20.818 13.253L15.4876 27.899C15.406 28.1232 15.2811 28.3291 15.12 28.505C14.9588 28.6809 14.7647 28.8233 14.5485 28.9241C14.3323 29.025 14.0984 29.0823 13.8601 29.0927C13.6218 29.1031 13.3837 29.0666 13.1596 28.985C12.9354 28.9034 12.7295 28.7785 12.5536 28.6174C12.3777 28.4563 12.2353 28.2621 12.1344 28.0459C12.0336 27.8297 11.9763 27.5958 11.9659 27.3575C11.9554 27.1192 11.992 26.8812 12.0736 26.657L17.404 12.011L17.4046 12.0104Z" fill="#4285F4"/>
|
||||
<path d="M13.6444 29.0856C14.0452 29.1262 14.448 29.032 14.7892 28.8179C15.1305 28.6038 15.3906 28.2821 15.5284 27.9036L19.858 16.0068C19.98 15.6718 20.0004 15.3082 19.9166 14.9616C19.8328 14.615 19.6486 14.3009 19.387 14.0586L13.6444 29.0856Z" fill="url(#paint13_linear_5436_78784)"/>
|
||||
<path d="M21.177 8.70422C21.177 8.40116 21.0566 8.11051 20.8423 7.89621C20.628 7.68191 20.3374 7.56152 20.0343 7.56152C19.7312 7.56152 19.4406 7.68191 19.2263 7.89621C19.012 8.11051 18.8916 8.40116 18.8916 8.70422V10.872C18.8916 11.1751 19.012 11.4657 19.2263 11.68C19.4406 11.8943 19.7312 12.0147 20.0343 12.0147C20.3374 12.0147 20.628 11.8943 20.8423 11.68C21.0566 11.4657 21.177 11.1751 21.177 10.872V8.70422Z" fill="#4285F4"/>
|
||||
<path d="M21.177 8.70422C21.177 8.40116 21.0566 8.11051 20.8423 7.89621C20.628 7.68191 20.3374 7.56152 20.0343 7.56152C19.7312 7.56152 19.4406 7.68191 19.2263 7.89621C19.012 8.11051 18.8916 8.40116 18.8916 8.70422V10.872C18.8916 11.1751 19.012 11.4657 19.2263 11.68C19.4406 11.8943 19.7312 12.0147 20.0343 12.0147C20.3374 12.0147 20.628 11.8943 20.8423 11.68C21.0566 11.4657 21.177 11.1751 21.177 10.872V8.70422Z" fill="url(#paint14_linear_5436_78784)"/>
|
||||
<g opacity="0.5" filter="url(#filter27_f_5436_78784)">
|
||||
<path d="M22.2981 25.2456C21.8517 25.284 22.5717 24.7314 22.4619 23.4462C22.3875 22.5798 22.6899 21.846 23.1363 21.8082C23.5827 21.7698 24.0051 22.4412 24.0795 23.3082C24.4497 24.696 22.7451 25.2078 22.2987 25.2462L22.2981 25.2456Z" fill="#044FCB"/>
|
||||
</g>
|
||||
<path d="M19.2144 13.2374C19.0565 12.7916 19.0808 12.3015 19.282 11.8736C19.4832 11.4456 19.8451 11.1143 20.2891 10.9515C20.7331 10.7887 21.2234 10.8077 21.6535 11.0043C22.0837 11.2008 22.4189 11.5591 22.5864 12.0014L27.963 26.675C28.0472 26.897 28.0864 27.1336 28.0785 27.3709C28.0706 27.6082 28.0158 27.8416 27.9171 28.0576C27.8184 28.2736 27.6778 28.4678 27.5035 28.6291C27.3293 28.7904 27.1247 28.9156 26.9018 28.9973C26.6788 29.079 26.4419 29.1157 26.2046 29.1053C25.9674 29.0948 25.7346 29.0374 25.5197 28.9364C25.3048 28.8353 25.1121 28.6927 24.9527 28.5167C24.7933 28.3407 24.6703 28.1348 24.591 27.911L19.2144 13.2374Z" fill="url(#paint15_linear_5436_78784)"/>
|
||||
<path d="M18.5989 11.4307C18.2653 11.5153 17.9574 11.6802 17.7021 11.9112C17.4468 12.1421 17.2519 12.4319 17.1343 12.7555L12.0811 26.6329C11.7727 27.4789 12.0811 28.3933 12.7393 28.8037L18.5989 11.4307Z" fill="url(#paint16_linear_5436_78784)"/>
|
||||
<path opacity="0.3" d="M19.0998 8.88428C19.3554 9.14048 19.8168 9.09428 20.1306 8.78048C20.4438 8.46728 20.4906 8.00528 20.2344 7.74908C19.9782 7.49348 19.5168 7.53968 19.203 7.85288C18.8898 8.16668 18.8436 8.62808 19.0998 8.88428Z" fill="url(#paint17_radial_5436_78784)" fill-opacity="0.9"/>
|
||||
<path opacity="0.3" d="M16.7042 16.7332C17.4698 17.1754 18.65 16.5652 19.34 15.37C20.03 14.1748 19.9688 12.8476 19.2032 12.406C18.437 11.9632 17.2574 12.574 16.5674 13.7692C15.8774 14.9644 15.9386 16.2916 16.7042 16.7332Z" fill="url(#paint18_radial_5436_78784)" fill-opacity="0.9"/>
|
||||
<path d="M20.0057 15.2384C20.5962 15.2384 21.1625 15.0038 21.5801 14.5862C21.9977 14.1686 22.2323 13.6023 22.2323 13.0118C22.2323 12.4212 21.9977 11.8549 21.5801 11.4373C21.1625 11.0197 20.5962 10.7852 20.0057 10.7852C19.4151 10.7852 18.8488 11.0197 18.4312 11.4373C18.0136 11.8549 17.7791 12.4212 17.7791 13.0118C17.7791 13.6023 18.0136 14.1686 18.4312 14.5862C18.8488 15.0038 19.4151 15.2384 20.0057 15.2384Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0057 11.3717C19.5705 11.3717 19.1531 11.5446 18.8453 11.8523C18.5376 12.1601 18.3647 12.5775 18.3647 13.0127C18.3647 13.4479 18.5376 13.8653 18.8453 14.1731C19.1531 14.4808 19.5705 14.6537 20.0057 14.6537C20.4409 14.6537 20.8583 14.4808 21.166 14.1731C21.4738 13.8653 21.6467 13.4479 21.6467 13.0127C21.6467 12.5775 21.4738 12.1601 21.166 11.8523C20.8583 11.5446 20.4409 11.3717 20.0057 11.3717ZM17.1929 13.0127C17.1929 12.6434 17.2656 12.2776 17.407 11.9364C17.5483 11.5952 17.7555 11.2851 18.0166 11.024C18.2778 10.7628 18.5878 10.5556 18.9291 10.4143C19.2703 10.2729 19.636 10.2002 20.0054 10.2002C20.3747 10.2002 20.7404 10.2729 21.0817 10.4143C21.4229 10.5556 21.7329 10.7628 21.9941 11.024C22.2553 11.2851 22.4624 11.5952 22.6038 11.9364C22.7451 12.2776 22.8179 12.6434 22.8179 13.0127C22.8179 13.7586 22.5216 14.474 21.9941 15.0014C21.4667 15.5289 20.7513 15.8252 20.0054 15.8252C19.2594 15.8252 18.5441 15.5289 18.0166 15.0014C17.4892 14.474 17.1929 13.7586 17.1929 13.0127Z" fill="#202124"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_5436_78784" x="22.6242" y="16.5466" width="6.29919" height="3.55334" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2106" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_5436_78784" x="24.2553" y="18.2043" width="5.04503" height="6.02794" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.3168" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_5436_78784" x="23.1339" y="15.9146" width="4.84796" height="1.75176" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1584" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter3_f_5436_78784" x="25.8976" y="20.7262" width="3.4002" height="3.94903" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1584" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter4_f_5436_78784" x="27.1516" y="15.9294" width="2.22619" height="1.93127" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2106" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter5_f_5436_78784" x="27.4024" y="15.8722" width="1.30084" height="1.63165" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0522" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter6_f_5436_78784" x="27.6812" y="16.0377" width="1.51177" height="2.08672" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1584" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter7_f_5436_78784" x="28.6939" y="21.0089" width="1.95148" height="0.632628" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0522" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter8_f_5436_78784" x="28.5522" y="20.4341" width="0.579341" height="1.66601" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1062" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter9_f_5436_78784" x="28.7395" y="20.2715" width="0.32573" height="0.604539" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.063" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter10_f_5436_78784" x="26.7388" y="16.6021" width="1.28808" height="1.33203" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1062" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter11_f_5436_78784" x="27.622" y="17.5068" width="0.515184" height="0.500047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.063" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter12_f_5436_78784" x="28.4228" y="20.5524" width="2.53161" height="1.3258" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2106" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter13_f_5436_78784" x="28.6322" y="20.193" width="2.35137" height="1.46368" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.1584" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter14_f_5436_78784" x="25.5827" y="17.569" width="1.08688" height="0.940147" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0324" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter15_f_5436_78784" x="26.8449" y="20.9957" width="1.00606" height="1.0173" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0324" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter16_d_5436_78784" x="25.6834" y="17.6426" width="0.95238" height="0.799059" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-0.0108" dy="0.0216"/>
|
||||
<feGaussianBlur stdDeviation="0.0108"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0156863 0 0 0 0 0.231373 0 0 0 0 0.0666667 0 0 0 0.7 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5436_78784"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5436_78784" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter17_d_5436_78784" x="26.9512" y="21.0547" width="0.854723" height="0.902575" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="-0.0108" dy="0.0216"/>
|
||||
<feGaussianBlur stdDeviation="0.0108"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0156863 0 0 0 0 0.231373 0 0 0 0 0.0666667 0 0 0 0.7 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5436_78784"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5436_78784" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter18_f_5436_78784" x="25.8099" y="17.6112" width="0.803454" height="0.345934" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0108" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter19_f_5436_78784" x="26.449" y="17.8909" width="0.170286" height="0.401731" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0018" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter20_f_5436_78784" x="27.2882" y="21.2675" width="0.516589" height="0.683825" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0108" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter21_f_5436_78784" x="26.1632" y="17.7264" width="0.266345" height="0.194567" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0108" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter22_f_5436_78784" x="27.5211" y="21.4257" width="0.182848" height="0.257067" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0108" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter23_f_5436_78784" x="25.9379" y="18.1894" width="0.553827" height="0.140741" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0054" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter24_f_5436_78784" x="27.0893" y="21.0829" width="0.395623" height="0.408319" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.0054" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter25_f_5436_78784" x="25.3322" y="16.6257" width="3.74157" height="6.19982" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2106" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter26_f_5436_78784" x="25.3322" y="16.6257" width="3.74157" height="6.19982" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2106" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<filter id="filter27_f_5436_78784" x="21.625" y="21.281" width="3.03216" height="4.49163" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="0.2628" result="effect1_foregroundBlur_5436_78784"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5436_78784" x1="27.7687" y1="30.9686" x2="26.6119" y2="30.3122" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4285F4"/>
|
||||
<stop offset="1" stop-color="#034ECA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5436_78784" x1="26.2425" y1="17.0595" x2="25.8759" y2="18.3591" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8F0B9"/>
|
||||
<stop offset="1" stop-color="#ADEEBC" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_5436_78784" x1="28.0568" y1="22.2021" x2="26.9558" y2="21.4209" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8F0B9"/>
|
||||
<stop offset="1" stop-color="#ADEEBC" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint3_radial_5436_78784" cx="0" cy="0" r="1" gradientTransform="matrix(-0.160932 -0.220716 0.159942 -0.116616 28.6025 16.4074)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.948" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_5436_78784" cx="0" cy="0" r="1" gradientTransform="matrix(-0.160931 -0.220715 0.15994 -0.116617 30.2426 20.8847)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.948" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint5_radial_5436_78784" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(26.5204 18.155) rotate(-156.205) scale(0.106566 0.1941)">
|
||||
<stop stop-color="#93E19F"/>
|
||||
<stop offset="1" stop-color="#93E19F" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint6_radial_5436_78784" cx="0" cy="0" r="1" gradientTransform="matrix(-0.0489 0.0946378 -0.172362 -0.0890638 27.576 21.1601)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#93E19F"/>
|
||||
<stop offset="1" stop-color="#93E19F" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint7_linear_5436_78784" x1="26.6638" y1="17.8861" x2="26.3572" y2="18.0097" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E2DDE2"/>
|
||||
<stop offset="1" stop-color="#E2DDE2" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_5436_78784" x1="27.7329" y1="21.3215" x2="27.4761" y2="21.4121" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#373637"/>
|
||||
<stop offset="1" stop-color="#373637" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_5436_78784" x1="28.4993" y1="19.1488" x2="27.2261" y2="19.5982" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8F0B9"/>
|
||||
<stop offset="1" stop-color="#ADEEBC" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint10_linear_5436_78784" x1="28.4993" y1="19.1488" x2="27.2261" y2="19.5982" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A8F0B9"/>
|
||||
<stop offset="1" stop-color="#ADEEBC" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint11_linear_5436_78784" x1="18.8629" y1="23.325" x2="24.4291" y2="22.8858" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4285F4"/>
|
||||
<stop offset="0.703" stop-color="#044FCB"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint12_linear_5436_78784" x1="12.5055" y1="30.6758" x2="13.7055" y2="29.9678" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4285F4"/>
|
||||
<stop offset="1" stop-color="#034ECA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint13_linear_5436_78784" x1="18.0118" y1="20.5998" x2="17.3722" y2="20.3778" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#73A7FF"/>
|
||||
<stop offset="1" stop-color="#5893F6" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint14_linear_5436_78784" x1="20.0346" y1="8.83982" x2="21.0402" y2="9.74462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4285F4"/>
|
||||
<stop offset="1" stop-color="#034ECA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint15_linear_5436_78784" x1="21.1596" y1="14.7602" x2="24.201" y2="21.1166" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#034DC9"/>
|
||||
<stop offset="1" stop-color="#4285F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint16_linear_5436_78784" x1="13.8001" y1="19.9981" x2="15.4021" y2="20.5483" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9BC0FF"/>
|
||||
<stop offset="1" stop-color="#5893F6" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint17_radial_5436_78784" cx="0" cy="0" r="1" gradientTransform="matrix(-0.353622 0.35982 -0.25068 -0.24636 19.6668 8.31668)" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.948" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint18_radial_5436_78784" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(17.9541 14.5691) rotate(119.61) scale(1.57072 0.858126)">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.948" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,97 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_5436_78917" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="4" width="32" height="32">
|
||||
<path d="M33.0017 34.7292C34.7884 36.1587 37.4682 35.2056 35.0115 32.5854C27.6419 24.9626 29.2053 4 20.049 4C10.8927 4 12.456 24.9626 5.08629 32.5854C2.40641 35.4438 5.3096 36.1587 7.09621 34.7292C14.0192 29.7269 13.5726 20.913 20.049 20.913C26.5253 20.913 26.0787 29.7269 33.0017 34.7292Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5436_78917)">
|
||||
<g filter="url(#filter0_f_5436_78917)">
|
||||
<path d="M2.6428 -3.09688C2.0994 2.00961 6.22486 6.70203 11.8573 7.38397C17.4897 8.06591 22.4962 4.47909 23.0395 -0.627401C23.5829 -5.73389 19.4575 -10.4263 13.8251 -11.1082C8.19268 -11.7902 3.1862 -8.20337 2.6428 -3.09688Z" fill="#FFE432"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_5436_78917)">
|
||||
<path d="M24.359 13.5949C25.7702 20.0758 31.9478 24.0825 38.1572 22.5441C44.3666 21.0058 48.2563 14.5049 46.8451 8.0241C45.4339 1.54324 39.2563 -2.46345 33.0469 -0.925119C26.8375 0.613209 22.9479 7.11405 24.359 13.5949Z" fill="#FC413D"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_5436_78917)">
|
||||
<path d="M-12.5892 17.9443C-10.8061 24.6327 -2.77556 28.0574 5.34752 25.5933C13.4706 23.1294 18.6101 15.7099 16.827 9.02145C15.0439 2.33301 7.0133 -1.09159 -1.10977 1.3724C-9.23284 3.83637 -14.3724 11.2559 -12.5892 17.9443Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_f_5436_78917)">
|
||||
<path d="M-12.5892 17.9443C-10.8061 24.6327 -2.77556 28.0574 5.34752 25.5933C13.4706 23.1294 18.6101 15.7099 16.827 9.02145C15.0439 2.33301 7.0133 -1.09159 -1.10977 1.3724C-9.23284 3.83637 -14.3724 11.2559 -12.5892 17.9443Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_f_5436_78917)">
|
||||
<path d="M-6.1442 23.4854C-1.67415 28.3554 6.02429 28.048 11.0507 22.7989C16.0772 17.5497 16.5282 9.34639 12.0581 4.47628C7.58808 -0.39381 -0.110356 -0.0864739 -5.13679 5.16274C-10.1632 10.412 -10.6143 18.6153 -6.1442 23.4854Z" fill="#00B95C"/>
|
||||
</g>
|
||||
<g filter="url(#filter5_f_5436_78917)">
|
||||
<path d="M17.2432 41.8532C18.6312 48.2281 24.4218 52.24 30.1768 50.8142C35.9317 49.3883 39.4718 43.0649 38.0837 36.6901C36.6958 30.3153 30.9053 26.3034 25.1501 27.7291C19.3952 29.155 15.8551 35.4784 17.2432 41.8532Z" fill="#3186FF"/>
|
||||
</g>
|
||||
<g filter="url(#filter6_f_5436_78917)">
|
||||
<path d="M7.43056 -9.06427C4.52402 -2.15801 7.70398 6.09157 14.5332 9.36164C21.3624 12.6317 29.2548 9.68399 32.1612 2.77772C35.0679 -4.12855 31.8879 -12.3781 25.0588 -15.6482C18.2295 -18.9183 10.3371 -15.9705 7.43056 -9.06427Z" fill="#FBBC04"/>
|
||||
</g>
|
||||
<g filter="url(#filter7_f_5436_78917)">
|
||||
<path d="M-0.355758 57.5976C-7.3922 54.6515 4.6329 30.4094 7.6231 22.2847C10.6134 14.1599 18.7416 9.96211 25.778 12.9086C32.8145 15.855 41.1549 32.0836 38.1644 40.2085C35.1742 48.3331 6.6807 60.544 -0.355758 57.5976Z" fill="#3186FF"/>
|
||||
</g>
|
||||
<g filter="url(#filter8_f_5436_78917)">
|
||||
<path d="M42.2801 27.4246C40.3961 29.7697 35.48 28.5738 31.2997 24.7533C27.1193 20.9328 25.2575 15.9345 27.1413 13.5892C29.0251 11.244 33.9411 12.4399 38.1215 16.2604C42.3019 20.0809 44.1638 25.0792 42.2801 27.4246Z" fill="#749BFF"/>
|
||||
</g>
|
||||
<g filter="url(#filter9_f_5436_78917)">
|
||||
<path d="M28.2171 15.4855C35.9652 21.076 44.8873 21.4433 48.1454 16.3058C51.4034 11.1683 47.7636 2.47155 40.0155 -3.11898C32.2675 -8.7095 23.3454 -9.07676 20.0874 -3.93929C16.8293 1.19821 20.4691 9.89496 28.2171 15.4855Z" fill="#FC413D"/>
|
||||
</g>
|
||||
<g filter="url(#filter10_f_5436_78917)">
|
||||
<path d="M2.78038 6.39313C0.860867 11.3323 1.48778 16.3015 4.18063 17.4922C6.87347 18.6829 10.6125 15.6442 12.5321 10.7051C14.4516 5.76593 13.8246 0.796692 11.1318 -0.394021C8.43895 -1.58473 4.69989 1.45397 2.78038 6.39313Z" fill="#FFEE48"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_5436_78917" x="0.0242057" y="-13.7619" width="25.634" height="23.7992" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="1.28526" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_5436_78917" x="11.6427" y="-13.681" width="47.9187" height="48.9812" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="6.21356" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_5436_78917" x="-23.5069" y="-9.99099" width="51.2515" height="46.9478" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.28212" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter3_f_5436_78917" x="-23.5069" y="-9.99099" width="51.2515" height="46.9478" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.28212" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter4_f_5436_78917" x="-19.7869" y="-9.54861" width="45.4878" height="47.0591" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.28212" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter5_f_5436_78917" x="6.90275" y="17.4023" width="41.5214" height="43.7384" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="5.01957" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter6_f_5436_78917" x="-2.74455" y="-26.0807" width="45.0809" height="45.8753" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="4.54916" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter7_f_5436_78917" x="-10.6564" y="3.84164" width="57.5706" height="62.3304" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="4.06258" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter8_f_5436_78917" x="19.1357" y="5.11809" width="31.15" height="30.7775" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="3.63529" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter9_f_5436_78917" x="12.7527" y="-13.7053" width="42.7273" height="39.7769" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="3.07044" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
<filter id="filter10_f_5436_78917" x="-5.93239" y="-8.2527" width="27.1773" height="33.604" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="3.80018" result="effect1_foregroundBlur_5436_78917"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5436_78947)">
|
||||
<mask id="mask0_5436_78947" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="4" y="4" width="32" height="32">
|
||||
<path d="M4 4H36V36H4V4Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5436_78947)">
|
||||
<path d="M28.375 4H11.625C7.41383 4 4 7.41383 4 11.625V28.375C4 32.5862 7.41383 36 11.625 36H28.375C32.5862 36 36 32.5862 36 28.375V11.625C36 7.41383 32.5862 4 28.375 4Z" fill="black"/>
|
||||
</g>
|
||||
<path d="M29.6214 14.2072L20.9502 9.12249C20.6718 8.95917 20.3282 8.95917 20.0498 9.12249L11.3786 14.2072C11.1445 14.3445 11 14.5984 11 14.8733V25.1267C11 25.4012 11.1445 25.6555 11.3786 25.7928L20.0498 30.8775C20.3282 31.0408 20.6718 31.0408 20.9502 30.8775L29.6214 25.7928C29.8555 25.6555 30 25.4016 30 25.1267V14.8733C30 14.5988 29.8555 14.3445 29.6214 14.2072ZM29.0767 15.2843L20.706 30.0101C20.6494 30.1093 20.5 30.0688 20.5 29.9538V20.3115C20.5 20.1189 20.3986 19.9407 20.2342 19.8439L12.0124 15.0226C11.9147 14.9651 11.9546 14.8134 12.0678 14.8134H28.8093C29.047 14.8134 29.1956 15.0751 29.0767 15.2843Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5436_78947">
|
||||
<rect width="32" height="32" fill="white" transform="translate(4 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.2414 8.05369L14.955 6.65636C14.7646 6.44946 14.5333 6.2843 14.2757 6.17133C14.0182 6.05836 13.7401 6.00002 13.4588 6H4.86684C4.753 5.99995 4.64025 6.02234 4.53506 6.06589C4.42987 6.10944 4.33429 6.17329 4.25379 6.25379C4.17329 6.33429 4.10944 6.42987 4.06589 6.53506C4.02234 6.64025 3.99995 6.753 4 6.86684V12.8779L4.04302 12.8825C4.10268 12.6706 4.22985 12.4839 4.40523 12.3508C4.5806 12.2177 4.7946 12.1455 5.01476 12.1451H13.189C14.3506 12.1451 15.4795 11.6366 16.1675 10.7004C16.5166 10.2255 16.9726 9.83942 17.4985 9.57327C18.0244 9.30713 18.6056 9.16842 19.195 9.16836H34.9852C35.2544 9.16836 35.5125 9.27527 35.7028 9.46557C35.8931 9.65587 36 9.91398 36 10.1831V8.99058C36 8.77152 35.913 8.56144 35.7581 8.40654C35.6032 8.25164 35.3931 8.16462 35.174 8.16462H16.4953C16.4476 8.16469 16.4004 8.15486 16.3567 8.13577C16.313 8.11667 16.2738 8.08873 16.2414 8.05369Z" fill="url(#paint0_linear_5437_79060)"/>
|
||||
<path d="M16.2002 10.3751C15.8497 10.8375 15.397 11.2125 14.8774 11.4707C14.3579 11.7289 13.7855 11.8634 13.2053 11.8634H5.01476C4.88147 11.8634 4.74948 11.8897 4.62634 11.9407C4.5032 11.9917 4.39132 12.0665 4.29709 12.1608C4.20286 12.255 4.12812 12.3669 4.07714 12.4901C4.02617 12.6133 3.99995 12.7453 4 12.8785V32.4302C4 32.6993 4.10691 32.9574 4.29722 33.1477C4.48752 33.338 4.74563 33.4449 5.01476 33.4449H34.9852C35.2544 33.4449 35.5125 33.338 35.7028 33.1477C35.8931 32.9574 36 32.6993 36 32.4302V9.90147C36 9.63234 35.8931 9.37424 35.7028 9.18393C35.5125 8.99363 35.2544 8.88672 34.9852 8.88672H19.195C18.6148 8.88679 18.0425 9.0212 17.5229 9.27942C17.0033 9.53764 16.5506 9.91265 16.2002 10.3751Z" fill="url(#paint1_linear_5437_79060)"/>
|
||||
<path d="M27.7632 23.2969H12.2368C11.7852 23.2968 11.3379 23.3857 10.9207 23.5585C10.5034 23.7312 10.1242 23.9846 9.80488 24.3039C9.48553 24.6233 9.23222 25.0024 9.05943 25.4197C8.88664 25.8369 8.79776 26.2842 8.79785 26.7358V33.4448H31.2025V26.7358C31.2021 24.8364 29.6626 23.2969 27.7632 23.2969Z" fill="url(#paint2_linear_5437_79060)"/>
|
||||
<path d="M26.5965 29.7865H13.404C13.2154 29.7865 13.0345 29.7116 12.9012 29.5782C12.7678 29.4448 12.6929 29.264 12.6929 29.0754C12.6929 28.8868 12.7678 28.7059 12.9012 28.5725C13.0345 28.4392 13.2154 28.3643 13.404 28.3643H26.5965C26.7851 28.3643 26.966 28.4392 27.0993 28.5725C27.2327 28.7059 27.3076 28.8868 27.3076 29.0754C27.3076 29.264 27.2327 29.4448 27.0993 29.5782C26.966 29.7116 26.7851 29.7865 26.5965 29.7865Z" fill="#10498A"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_5437_79060" x1="19.7682" y1="9.59964" x2="19.2608" y2="6.75804" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#CE9918"/>
|
||||
<stop offset="0.5902" stop-color="#DF9E00"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5437_79060" x1="26.3559" y1="28.492" x2="13.062" y2="13.1687" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F5B509"/>
|
||||
<stop offset="1" stop-color="#FECE49"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_5437_79060" x1="27.4873" y1="32.5737" x2="7.9015" y2="23.2375" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0161B2"/>
|
||||
<stop offset="1" stop-color="#1291DD"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5437_79067)">
|
||||
<path d="M27.395 36C26.1435 35.9986 24.9203 35.6274 23.879 34.933C22.8374 35.6273 21.6139 35.9985 20.362 36C19.113 36 17.882 35.625 16.846 34.933C15.8189 35.6241 14.61 35.9954 13.372 36H13.334C12.4944 36.0006 11.6633 35.8321 10.8902 35.5047C10.1171 35.1772 9.41781 34.6975 8.83405 34.094C7.65642 32.8996 6.99679 31.2893 6.99805 29.612V17.362C7.00005 9.995 12.994 4 20.362 4C27.731 4 33.725 9.994 33.725 17.363V29.616C33.725 33.009 31.099 35.808 27.747 35.991C27.63 35.998 27.513 36 27.395 36Z" fill="#3551F3"/>
|
||||
<path d="M27.395 34.5932C26.2752 34.5924 25.1889 34.2104 24.315 33.5102C24.1961 33.4145 24.0476 33.3632 23.895 33.3652C23.7174 33.367 23.5457 33.4291 23.408 33.5412C22.5392 34.2237 21.4668 34.5951 20.362 34.5962C19.2576 34.5949 18.1855 34.2234 17.317 33.5412C17.1835 33.4338 17.0174 33.3752 16.846 33.3752C16.6747 33.3752 16.5085 33.4338 16.375 33.5412C15.518 34.2199 14.4581 34.5914 13.365 34.5962H13.332C12.6806 34.5961 12.0359 34.4649 11.4362 34.2102C10.8366 33.9556 10.2945 33.5829 9.84201 33.1142C8.92225 32.1822 8.40639 30.9256 8.40601 29.6162V17.3672C8.40601 10.7702 13.77 5.40723 20.363 5.40723C26.955 5.40723 32.319 10.7702 32.319 17.3632V29.6162C32.319 32.2612 30.277 34.4432 27.669 34.5862C27.5777 34.5909 27.4864 34.5932 27.395 34.5932Z" fill="black"/>
|
||||
<path d="M30.912 17.3635V29.6165C30.912 31.4925 29.465 33.0795 27.592 33.1825C26.725 33.2323 25.8704 32.9582 25.194 32.4135C24.416 31.7875 23.321 31.8155 22.536 32.4345C21.9163 32.9237 21.1495 33.1891 20.36 33.1875C19.5714 33.1889 18.8056 32.9235 18.187 32.4345C17.8057 32.1306 17.3326 31.9651 16.845 31.9651C16.3574 31.9651 15.8843 32.1306 15.503 32.4345C14.8904 32.9183 14.1336 33.1834 13.353 33.1875C11.405 33.2015 9.81299 31.5605 9.81299 29.6125V17.3625C9.81299 11.5375 14.537 6.81348 20.363 6.81348C26.188 6.81348 30.912 11.5375 30.912 17.3635Z" fill="white"/>
|
||||
<path d="M18.28 16.4366L14.35 14.1666C14.104 14.0248 13.8117 13.9865 13.5374 14.06C13.2632 14.1335 13.0292 14.3128 12.887 14.5586C12.7452 14.8045 12.7067 15.0966 12.78 15.3709C12.8533 15.6452 13.0324 15.8792 13.278 16.0216L15.604 17.3646L13.278 18.7076C13.045 18.8559 12.8783 19.0886 12.8126 19.3569C12.7469 19.6252 12.7873 19.9085 12.9255 20.1478C13.0636 20.387 13.2887 20.5637 13.554 20.641C13.8192 20.7183 14.104 20.6902 14.349 20.5626L18.281 18.2926C18.4436 18.1985 18.5786 18.0633 18.6724 17.9006C18.7662 17.7379 18.8156 17.5534 18.8156 17.3656C18.8156 17.1777 18.7662 16.9932 18.6724 16.8305C18.5786 16.6678 18.4436 16.5326 18.281 16.4386V16.4366H18.28ZM27.182 16.2906H22.018C21.7338 16.2906 21.4613 16.4034 21.2604 16.6044C21.0594 16.8053 20.9465 17.0779 20.9465 17.3621C20.9465 17.6462 21.0594 17.9188 21.2604 18.1197C21.4613 18.3207 21.7338 18.4336 22.018 18.4336H27.182C27.4662 18.4336 27.7387 18.3207 27.9397 18.1197C28.1406 17.9188 28.2535 17.6462 28.2535 17.3621C28.2535 17.0779 28.1406 16.8053 27.9397 16.6044C27.7387 16.4034 27.4662 16.2906 27.182 16.2906Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5437_79067">
|
||||
<rect width="27" height="32" fill="white" transform="translate(7 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="32" height="32" rx="6.4" fill="url(#paint0_linear_5437_79079)"/>
|
||||
<rect x="4.80005" y="4.7998" width="30.4" height="30.4" rx="5.6" fill="black"/>
|
||||
<rect x="7.19995" y="7.2002" width="25.6" height="25.6" rx="3.2" fill="#202A2F"/>
|
||||
<path d="M25.2454 8.85742H19.2454V20.8574H25.2454V8.85742Z" fill="#0EE827" fill-opacity="0.35"/>
|
||||
<path d="M11.7895 18.9218C11.4758 18.8949 11.1817 18.85 10.9072 18.7872C10.6424 18.7154 10.3973 18.6392 10.1718 18.5584C9.95613 18.4776 9.76496 18.4014 9.59829 18.3296C9.44145 18.2488 9.32378 18.186 9.24536 18.1412L9.87771 16.9567C9.94631 17.0016 10.064 17.0644 10.2306 17.1452C10.3973 17.2259 10.5836 17.3111 10.7895 17.4009C11.0052 17.4816 11.2257 17.5534 11.4513 17.6162C11.6768 17.679 11.8875 17.7105 12.0836 17.7105C13.064 17.7105 13.5542 17.3381 13.5542 16.5933C13.5542 16.4138 13.5199 16.2658 13.4512 16.1492C13.3925 16.0325 13.2993 15.9338 13.1719 15.853C13.0542 15.7633 12.9071 15.687 12.7307 15.6242C12.564 15.5525 12.3729 15.4806 12.1571 15.4089C11.7453 15.2653 11.3777 15.1172 11.0542 14.9647C10.7405 14.8032 10.4708 14.6237 10.2454 14.4263C10.0199 14.2289 9.8483 14.0046 9.73063 13.7534C9.61303 13.5021 9.5542 13.2149 9.5542 12.8919C9.5542 12.6407 9.60812 12.3984 9.71596 12.1651C9.82379 11.9318 9.97572 11.7254 10.1718 11.546C10.3679 11.3576 10.6032 11.2005 10.8777 11.0749C11.1522 10.9403 11.4562 10.8461 11.7895 10.7922V8.85742H12.9954V10.7653C13.2503 10.7922 13.4954 10.8326 13.7307 10.8865C13.966 10.9403 14.1767 10.9986 14.363 11.0614C14.5493 11.1242 14.7062 11.1871 14.8336 11.2499C14.9709 11.3037 15.0689 11.3441 15.1278 11.371L14.5395 12.4747C14.4611 12.4388 14.3483 12.3939 14.2012 12.3401C14.064 12.2773 13.9072 12.2144 13.7307 12.1517C13.5542 12.0888 13.363 12.035 13.1571 11.9901C12.9513 11.9453 12.7454 11.9228 12.5395 11.9228C12.2062 11.9228 11.9071 11.9856 11.6424 12.1112C11.3875 12.2279 11.2601 12.4298 11.2601 12.7169C11.2601 12.8964 11.2944 13.058 11.363 13.2015C11.4414 13.3361 11.5493 13.4573 11.6865 13.5649C11.8336 13.6726 12.0101 13.7758 12.2159 13.8745C12.4316 13.9642 12.6768 14.054 12.9513 14.1437C13.314 14.2782 13.6376 14.4173 13.9218 14.5609C14.2062 14.7045 14.4463 14.866 14.6424 15.0455C14.8385 15.2249 14.9856 15.4313 15.0836 15.6646C15.1914 15.8979 15.2454 16.1715 15.2454 16.4856C15.2454 16.7548 15.1964 17.015 15.0983 17.2662C15.0101 17.5175 14.8679 17.7508 14.6718 17.9661C14.4856 18.1815 14.2503 18.3699 13.966 18.5315C13.6915 18.684 13.3679 18.7962 12.9954 18.8679V20.8574H11.7895V18.9218Z" fill="#0EE827"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_5437_79079" x1="20" y1="4" x2="20" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D4E6E8"/>
|
||||
<stop offset="1" stop-color="#767573"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.9013 7.2002H10.604C9.83641 7.2002 9.09532 7.84723 8.91004 8.66305L4.03999 31.3373C3.85472 32.1532 4.33113 32.8002 5.0987 32.8002H29.396C30.1636 32.8002 30.9047 32.1532 31.09 31.3373L35.96 8.69118C36.1453 7.87536 35.6689 7.2002 34.9013 7.2002Z" fill="url(#paint0_linear_5437_79143)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7489 26.5009H25.2421C25.8826 26.5009 26.3999 27.0942 26.3999 27.8223C26.3999 28.5505 25.8826 29.1438 25.2421 29.1438H19.7489C19.1085 29.1438 18.5912 28.5505 18.5912 27.8223C18.5912 27.0942 19.1085 26.5009 19.7489 26.5009ZM23.3208 20.6216C23.2222 20.8374 23.0252 21.0531 22.6803 21.3228L12.4575 29.3596C11.8909 29.7911 11.1027 29.6293 10.6593 28.982C10.2405 28.3347 10.339 27.4448 10.881 27.0133L20.0938 19.7047V19.5429L14.305 12.8006C13.837 12.2612 13.8862 11.3713 14.4035 10.8049C14.9455 10.2655 15.7584 10.2655 16.2264 10.8049L23.173 18.8956C23.5917 19.408 23.6163 20.1092 23.3208 20.6216Z" fill="#2C5591"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.2368 20.6216C23.1387 20.8374 22.9426 21.0531 22.5993 21.3228L12.448 29.3596C11.8841 29.7911 11.0994 29.6293 10.6581 28.982C10.2412 28.3347 10.3393 27.4448 10.8788 27.0133L20.0492 19.7047V19.5429L14.287 12.8006C13.8212 12.2612 13.8702 11.3713 14.3851 10.8049C14.9246 10.2655 15.7337 10.2655 16.1996 10.8049L23.1142 18.8956C23.5311 19.3811 23.5556 20.0822 23.2368 20.6216ZM19.7795 26.6357H25.2475C25.885 26.6357 26.3999 27.202 26.3999 27.8763C26.3999 28.5775 25.885 29.1168 25.2475 29.1168H19.7795C19.142 29.1168 18.6271 28.5505 18.6271 27.8763C18.6271 27.1751 19.142 26.6357 19.7795 26.6357Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_5437_79143" x1="9.76654" y1="10.3425" x2="29.068" y2="26.4694" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2D4664"/>
|
||||
<stop offset="0.169" stop-color="#29405B"/>
|
||||
<stop offset="0.445" stop-color="#1E2F43"/>
|
||||
<stop offset="0.79" stop-color="#0C131B"/>
|
||||
<stop offset="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5436_78897)">
|
||||
<path d="M35.911 20.0773C35.911 19.7026 35.552 19.4866 35.1114 19.5939L4.79962 27.0055C4.35812 27.1135 4 27.5052 4 27.8792V35.4541C4 35.8288 4.35812 36.0455 4.79962 35.9375L35.1114 28.5266C35.552 28.4187 35.911 28.0269 35.911 27.6523V20.0774V20.0773Z" fill="url(#paint0_linear_5436_78897)"/>
|
||||
<path d="M4 19.8353C4 20.21 4.35812 20.6016 4.79962 20.7097L35.1159 28.1219C35.5573 28.2299 35.9155 28.0133 35.9155 27.6393V20.0637C35.9155 19.6897 35.5573 19.298 35.1159 19.19L4.79962 11.7778C4.35812 11.6699 4 11.8858 4 12.2603V19.8353V19.8353Z" fill="#FF9800"/>
|
||||
<path d="M35.911 4.51106C35.911 4.1365 35.552 3.91973 35.1114 4.02778L4.79962 11.4387C4.35812 11.5466 4 11.9383 4 12.313V19.8879C4 20.2626 4.35812 20.4786 4.79962 20.3712L35.1114 12.9596C35.552 12.8517 35.911 12.46 35.911 12.086V4.51106Z" fill="#FF9800"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_5436_78897" x1="1762.84" y1="981.852" x2="1845.15" y2="624.882" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9700"/>
|
||||
<stop offset="0.53" stop-color="#F48E00"/>
|
||||
<stop offset="1" stop-color="#D06F00"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_5436_78897">
|
||||
<rect width="32" height="32" fill="white" transform="translate(4 4)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 969 B |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_5437_79124" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="5" y="5" width="30" height="30">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.0227 34.1285C26.2519 34.2179 26.4969 34.2592 26.7427 34.2497C26.9884 34.2403 27.2296 34.1804 27.4512 34.0738L33.3809 31.2206C33.6863 31.0736 33.9439 30.8433 34.1243 30.5563C34.3046 30.2693 34.4002 29.9372 34.4001 29.5983V10.2522C34.4001 9.91323 34.3044 9.58119 34.1241 9.29424C33.9437 9.00729 33.686 8.77709 33.3806 8.63014L27.4518 5.77692C27.1167 5.6157 26.7398 5.56263 26.3733 5.62507C26.0067 5.6875 25.6687 5.86235 25.4059 6.1254L14.0543 16.4807L9.10994 12.7281C8.88691 12.5587 8.61163 12.4728 8.33186 12.4852C8.05209 12.4976 7.78549 12.6076 7.57835 12.7961L5.99262 14.2387C5.86908 14.3511 5.77035 14.488 5.70276 14.6408C5.63517 14.7935 5.60021 14.9587 5.6001 15.1257C5.59999 15.2928 5.63474 15.458 5.70213 15.6108C5.76952 15.7636 5.86807 15.9007 5.99147 16.0133L10.2792 19.9252L5.99176 23.8371C5.86836 23.9497 5.76981 24.0868 5.70242 24.2396C5.63503 24.3925 5.60028 24.5577 5.60039 24.7247C5.60049 24.8917 5.63546 25.0569 5.70305 25.2097C5.77064 25.3624 5.86936 25.4994 5.99291 25.6118L7.57864 27.0547C7.78578 27.2431 8.05237 27.3531 8.33214 27.3655C8.61191 27.3779 8.8872 27.292 9.11022 27.1226L14.0546 23.3694L25.4059 33.7253C25.5814 33.9011 25.7913 34.0384 26.0227 34.1285ZM27.2044 13.3876L18.5918 19.9252L27.2047 26.4631L27.2044 13.3876Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5437_79124)">
|
||||
<path d="M33.3807 8.6342L27.4467 5.77724C27.1116 5.61588 26.7346 5.56266 26.368 5.62494C26.0013 5.68722 25.663 5.86191 25.3999 6.12486L5.97403 23.8369C5.85059 23.9494 5.75201 24.0865 5.68459 24.2393C5.61718 24.3922 5.58241 24.5574 5.58252 24.7245C5.58263 24.8915 5.61761 25.0567 5.68522 25.2094C5.75284 25.3622 5.8516 25.4991 5.97518 25.6115L7.56206 27.0541C7.76938 27.2425 8.03605 27.3524 8.31587 27.3648C8.59569 27.3772 8.87105 27.2913 9.09422 27.1221L32.4879 9.37551C33.2727 8.78022 34.3999 9.3398 34.3999 10.3251V10.2559C34.3999 9.91713 34.3042 9.58524 34.1239 9.2984C33.9436 9.01157 33.686 8.78145 33.3807 8.63449V8.6342Z" fill="#0065A9"/>
|
||||
<g filter="url(#filter0_d_5437_79124)">
|
||||
<path d="M33.3807 31.2161L27.4467 34.0731C27.1116 34.2345 26.7346 34.2877 26.3679 34.2255C26.0012 34.1632 25.663 33.9885 25.3999 33.7255L5.97403 16.0135C5.85059 15.9009 5.75201 15.7638 5.68459 15.611C5.61718 15.4582 5.58241 15.2929 5.58252 15.1259C5.58263 14.9588 5.61761 14.7937 5.68522 14.6409C5.75284 14.4882 5.8516 14.3512 5.97518 14.2388L7.56206 12.7962C7.76938 12.6079 8.03605 12.498 8.31587 12.4856C8.59569 12.4731 8.87105 12.559 9.09422 12.7282L32.4879 30.4748C33.2727 31.0701 34.3999 30.5105 34.3999 29.5253V29.5944C34.3999 29.9332 34.3042 30.2651 34.1239 30.5519C33.9436 30.8388 33.686 31.0692 33.3807 31.2161Z" fill="#007ACC"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_5437_79124)">
|
||||
<path d="M27.447 34.0738C27.1118 34.235 26.7347 34.2881 26.3679 34.2257C26.0012 34.1633 25.6629 33.9884 25.3999 33.7253C26.064 34.3898 27.1999 33.9194 27.1999 32.98V6.87104C27.1999 5.93159 26.064 5.46128 25.3999 6.12541C25.6629 5.86234 26.0012 5.68749 26.3679 5.62506C26.7347 5.56263 27.1118 5.6157 27.447 5.77693L33.3798 8.63044C33.6853 8.77731 33.9432 9.00748 34.1236 9.29443C34.3041 9.58139 34.3999 9.91347 34.3999 10.2525V29.5986C34.3999 29.9376 34.3042 30.2697 34.1238 30.5567C33.9434 30.8437 33.6856 31.0739 33.3801 31.2209L27.447 34.0738Z" fill="#1F9CF0"/>
|
||||
</g>
|
||||
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M26.0052 34.1285C26.2343 34.2179 26.4793 34.2591 26.7251 34.2497C26.9709 34.2403 27.212 34.1804 27.4336 34.0738L33.3633 31.2206C33.6687 31.0736 33.9265 30.8434 34.1068 30.5564C34.2872 30.2694 34.3829 29.9372 34.3828 29.5983V10.2522C34.3828 9.91323 34.2871 9.58119 34.1068 9.29424C33.9264 9.00729 33.6687 8.77709 33.3633 8.63014L27.4339 5.77692C27.0989 5.6157 26.722 5.56263 26.3554 5.62507C25.9889 5.6875 25.6508 5.86235 25.388 6.1254L14.0367 16.4807L9.09236 12.7281C8.86936 12.5588 8.59413 12.4729 8.31442 12.4853C8.03472 12.4977 7.76818 12.6077 7.56106 12.7961L5.97504 14.2387C5.8515 14.3511 5.75277 14.488 5.68518 14.6408C5.6176 14.7935 5.58263 14.9587 5.58252 15.1257C5.58241 15.2928 5.61716 15.458 5.68456 15.6108C5.75195 15.7636 5.85049 15.9007 5.97389 16.0133L10.2616 19.9252L5.97389 23.8371C5.85049 23.9497 5.75195 24.0868 5.68456 24.2396C5.61716 24.3925 5.58241 24.5577 5.58252 24.7247C5.58263 24.8917 5.6176 25.0569 5.68518 25.2097C5.75277 25.3624 5.8515 25.4994 5.97504 25.6118L7.56106 27.0544C7.76818 27.2428 8.03472 27.3527 8.31442 27.3651C8.59413 27.3775 8.86936 27.2916 9.09236 27.1223L14.0367 23.3697L25.388 33.7256C25.5635 33.9014 25.7737 34.0384 26.0052 34.1285ZM27.1868 13.3876L18.5739 19.9252L27.1868 26.4631V13.3876Z" fill="url(#paint0_linear_5437_79124)" style="mix-blend-mode:overlay"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_5437_79124" x="-7.75188" y="-0.850024" width="55.4862" height="48.4354" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6.6672"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow_5437_79124"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5437_79124" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_5437_79124" x="12.0655" y="-7.73479" width="35.6688" height="55.3202" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="6.6672"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow_5437_79124"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5437_79124" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5437_79124" x1="19.9825" y1="5.59951" x2="19.9825" y2="34.2509" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5437_79100)">
|
||||
<mask id="mask0_5437_79100" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="5" y="5" width="30" height="30">
|
||||
<path d="M5.6001 5.59961H34.4001V34.3996H5.6001V5.59961Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5437_79100)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.3001 7.39961C8.0614 7.39961 7.83248 7.49443 7.6637 7.66321C7.49492 7.832 7.4001 8.06091 7.4001 8.29961V28.0996H5.6001V8.29961C5.6001 7.58352 5.88456 6.89677 6.39091 6.39042C6.89726 5.88407 7.58401 5.59961 8.3001 5.59961H32.4138C33.6165 5.59961 34.2186 7.05371 33.3684 7.90421L18.5166 22.7557H22.7001V20.8996H24.5001V23.206C24.5001 23.5641 24.3579 23.9074 24.1047 24.1606C23.8515 24.4138 23.5081 24.556 23.1501 24.556H16.7166L13.623 27.6496H27.6501V16.3996H29.4501V27.6496C29.4501 28.127 29.2605 28.5848 28.9229 28.9224C28.5853 29.26 28.1275 29.4496 27.6501 29.4496H11.823L8.673 32.5996H31.7001C31.9388 32.5996 32.1677 32.5048 32.3365 32.336C32.5053 32.1672 32.6001 31.9383 32.6001 31.6996V11.8996H34.4001V31.6996C34.4001 32.4157 34.1156 33.1024 33.6093 33.6088C33.1029 34.1151 32.4162 34.3996 31.7001 34.3996H7.5864C6.3837 34.3996 5.7816 32.9455 6.6318 32.095L21.4272 17.2996H17.3001V19.0996H15.5001V16.8496C15.5001 16.4916 15.6423 16.1482 15.8955 15.895C16.1487 15.6418 16.4921 15.4996 16.8501 15.4996H23.2272L26.3772 12.3496H12.3501V23.5996H10.5501V12.3496C10.5501 11.8722 10.7397 11.4144 11.0773 11.0768C11.4149 10.7393 11.8727 10.5496 12.3501 10.5496H28.1772L31.3272 7.39961H8.3001Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5437_79100">
|
||||
<rect width="28.8" height="28.8" fill="white" transform="translate(5.6001 5.59961)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5437_79109)">
|
||||
<mask id="mask0_5437_79109" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="5" y="5" width="30" height="30">
|
||||
<path d="M5.6001 5.59961H34.4001V34.3996H5.6001V5.59961Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_5437_79109)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.3001 7.39961C8.0614 7.39961 7.83248 7.49443 7.6637 7.66321C7.49492 7.832 7.4001 8.06091 7.4001 8.29961V28.0996H5.6001V8.29961C5.6001 7.58352 5.88456 6.89677 6.39091 6.39042C6.89726 5.88407 7.58401 5.59961 8.3001 5.59961H32.4138C33.6165 5.59961 34.2186 7.05371 33.3684 7.90421L18.5166 22.7557H22.7001V20.8996H24.5001V23.206C24.5001 23.5641 24.3579 23.9074 24.1047 24.1606C23.8515 24.4138 23.5081 24.556 23.1501 24.556H16.7166L13.623 27.6496H27.6501V16.3996H29.4501V27.6496C29.4501 28.127 29.2605 28.5848 28.9229 28.9224C28.5853 29.26 28.1275 29.4496 27.6501 29.4496H11.823L8.673 32.5996H31.7001C31.9388 32.5996 32.1677 32.5048 32.3365 32.336C32.5053 32.1672 32.6001 31.9383 32.6001 31.6996V11.8996H34.4001V31.6996C34.4001 32.4157 34.1156 33.1024 33.6093 33.6088C33.1029 34.1151 32.4162 34.3996 31.7001 34.3996H7.5864C6.3837 34.3996 5.7816 32.9455 6.6318 32.095L21.4272 17.2996H17.3001V19.0996H15.5001V16.8496C15.5001 16.4916 15.6423 16.1482 15.8955 15.895C16.1487 15.6418 16.4921 15.4996 16.8501 15.4996H23.2272L26.3772 12.3496H12.3501V23.5996H10.5501V12.3496C10.5501 11.8722 10.7397 11.4144 11.0773 11.0768C11.4149 10.7393 11.8727 10.5496 12.3501 10.5496H28.1772L31.3272 7.39961H8.3001Z" fill="black"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5437_79109">
|
||||
<rect width="28.8" height="28.8" fill="white" transform="translate(5.6001 5.59961)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -6,7 +6,9 @@ interface UseResizablePanelOptions {
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
side?: 'left' | 'right';
|
||||
side?: 'left' | 'right' | 'top' | 'bottom';
|
||||
/** Drag axis: 'x' resizes width (default), 'y' resizes height (uses clientY). */
|
||||
axis?: 'x' | 'y';
|
||||
/**
|
||||
* When provided, dragging the panel narrower than `snapCloseRatio * minWidth`
|
||||
* snaps it shut (calls this) instead of clamping at minWidth.
|
||||
@@ -37,6 +39,7 @@ export function useResizablePanel({
|
||||
minWidth = 200,
|
||||
maxWidth = 600,
|
||||
side = 'right',
|
||||
axis = 'x',
|
||||
onSnapClose,
|
||||
snapCloseRatio = 0.6,
|
||||
apply,
|
||||
@@ -73,7 +76,7 @@ export function useResizablePanel({
|
||||
rafRef.current = null;
|
||||
if (!draggingRef.current) return;
|
||||
const delta =
|
||||
side === 'right'
|
||||
side === 'right' || side === 'bottom'
|
||||
? startXRef.current - latestXRef.current
|
||||
: latestXRef.current - startXRef.current;
|
||||
const raw = startWidthRef.current + delta;
|
||||
@@ -102,9 +105,10 @@ export function useResizablePanel({
|
||||
// Only primary button / touch / pen.
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
startXRef.current = e.clientX;
|
||||
const pos = axis === 'y' ? e.clientY : e.clientX;
|
||||
startXRef.current = pos;
|
||||
startWidthRef.current = widthRef.current;
|
||||
latestXRef.current = e.clientX;
|
||||
latestXRef.current = pos;
|
||||
snappedRef.current = false;
|
||||
draggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
@@ -115,7 +119,7 @@ export function useResizablePanel({
|
||||
// moves once the pointer leaves it).
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
latestXRef.current = ev.clientX;
|
||||
latestXRef.current = axis === 'y' ? ev.clientY : ev.clientX;
|
||||
if (rafRef.current == null) rafRef.current = requestAnimationFrame(flush);
|
||||
};
|
||||
const cleanup = () => {
|
||||
@@ -144,7 +148,7 @@ export function useResizablePanel({
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
},
|
||||
[flush, storageKey],
|
||||
[flush, storageKey, axis],
|
||||
);
|
||||
|
||||
const resetWidth = useCallback(() => {
|
||||
@@ -156,6 +160,8 @@ export function useResizablePanel({
|
||||
|
||||
return {
|
||||
width,
|
||||
/** Alias for `width` — reads clearer when axis is 'y' (it's a height). */
|
||||
size: width,
|
||||
isDragging,
|
||||
handleProps: {
|
||||
isDragging,
|
||||
|
||||
@@ -78,6 +78,21 @@ export function setAutoCloseDelay(delay: AutoCloseDelay): void {
|
||||
setItem(AUTO_CLOSE_KEY, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-used "Open in app" target.
|
||||
* Stores the app id from the OPEN_IN_APPS catalog (packages/shared/open-in-apps.ts).
|
||||
* Defaults to 'reveal' (the file manager) when unset.
|
||||
*/
|
||||
const OPEN_IN_APP_KEY = 'plannotator-open-in-app';
|
||||
|
||||
export function getLastOpenInApp(): string {
|
||||
return getItem(OPEN_IN_APP_KEY) ?? 'reveal';
|
||||
}
|
||||
|
||||
export function setLastOpenInApp(id: string): void {
|
||||
setItem(OPEN_IN_APP_KEY, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage object with localStorage-like API
|
||||
*/
|
||||
|
||||