mirror of
https://github.com/different-ai/openwork
synced 2026-05-09 08:42:08 +02:00
feat(app): simplify artifacts and add worker/plugin quick actions
Remove the in-app artifact editor in favor of read-only artifact actions, with reveal and optional Obsidian open for markdown files. Add workspace reveal from worker menus and plugin removal controls so users can manage workers and plugins directly in the UI. Made-with: Cursor
This commit is contained in:
@@ -2076,6 +2076,7 @@ export default function App() {
|
||||
refreshHubSkills,
|
||||
refreshPlugins,
|
||||
addPlugin,
|
||||
removePlugin,
|
||||
importLocalSkill,
|
||||
installSkillCreator,
|
||||
installHubSkill,
|
||||
@@ -5636,6 +5637,7 @@ export default function App() {
|
||||
isPluginInstalled: isPluginInstalledByName,
|
||||
suggestedPlugins: SUGGESTED_PLUGINS,
|
||||
addPlugin,
|
||||
removePlugin,
|
||||
createSessionAndOpen,
|
||||
setPrompt,
|
||||
selectSession: selectSession,
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import { FileText, RefreshCcw, Save, X } from "lucide-solid";
|
||||
|
||||
import Button from "../button";
|
||||
import LiveMarkdownEditor from "../live-markdown-editor";
|
||||
import type { OpenworkServerClient, OpenworkWorkspaceFileContent, OpenworkWorkspaceFileWriteResult } from "../../lib/openwork-server";
|
||||
import { OpenworkServerError } from "../../lib/openwork-server";
|
||||
|
||||
export type ArtifactMarkdownEditorProps = {
|
||||
open: boolean;
|
||||
path: string | null;
|
||||
workspaceId: string | null;
|
||||
client: OpenworkServerClient | null;
|
||||
onClose: () => void;
|
||||
onToast?: (message: string) => void;
|
||||
};
|
||||
|
||||
const isMarkdown = (value: string) => /\.(md|mdx|markdown)$/i.test(value);
|
||||
const basename = (value: string) => value.split(/[/\\]/).filter(Boolean).pop() ?? value;
|
||||
|
||||
export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProps) {
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [original, setOriginal] = createSignal("");
|
||||
const [draft, setDraft] = createSignal("");
|
||||
const [loadedPath, setLoadedPath] = createSignal<string | null>(null);
|
||||
const [resolvedPath, setResolvedPath] = createSignal<string | null>(null);
|
||||
const [baseUpdatedAt, setBaseUpdatedAt] = createSignal<number | null>(null);
|
||||
|
||||
const [confirmDiscardClose, setConfirmDiscardClose] = createSignal(false);
|
||||
const [confirmOverwrite, setConfirmOverwrite] = createSignal(false);
|
||||
|
||||
const [pendingPath, setPendingPath] = createSignal<string | null>(null);
|
||||
const [pendingReason, setPendingReason] = createSignal<"switch" | null>(null);
|
||||
|
||||
const path = createMemo(() => props.path?.trim() ?? "");
|
||||
const title = createMemo(() => (path() ? basename(path()) : "Artifact"));
|
||||
const dirty = createMemo(() => draft() !== original());
|
||||
const canWrite = createMemo(() => Boolean(props.client && props.workspaceId));
|
||||
const canSave = createMemo(() => dirty() && !saving() && canWrite());
|
||||
const writeDisabledReason = createMemo(() => {
|
||||
if (canWrite()) return null;
|
||||
return "Connect to an OpenWork server worker to edit files.";
|
||||
});
|
||||
|
||||
const resetState = () => {
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setOriginal("");
|
||||
setDraft("");
|
||||
setLoadedPath(null);
|
||||
setResolvedPath(null);
|
||||
setBaseUpdatedAt(null);
|
||||
setConfirmDiscardClose(false);
|
||||
setConfirmOverwrite(false);
|
||||
setPendingPath(null);
|
||||
setPendingReason(null);
|
||||
};
|
||||
|
||||
const load = async (target: string) => {
|
||||
const client = props.client;
|
||||
const workspaceId = props.workspaceId;
|
||||
|
||||
if (!client || !workspaceId) {
|
||||
setError(writeDisabledReason());
|
||||
return;
|
||||
}
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!isMarkdown(target)) {
|
||||
setError("Only markdown files are supported.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResolvedPath(null);
|
||||
try {
|
||||
let result: OpenworkWorkspaceFileContent;
|
||||
let actualPath = target;
|
||||
try {
|
||||
result = (await client.readWorkspaceFile(workspaceId, target)) as OpenworkWorkspaceFileContent;
|
||||
} catch (err) {
|
||||
// Artifacts are frequently referenced as workspace-relative paths (e.g. `learned/foo.md`),
|
||||
// but on disk they may live under the OpenWork outbox dir: `.opencode/openwork/outbox/`.
|
||||
// If the first lookup fails, retry there.
|
||||
const candidateOutbox = `.opencode/openwork/outbox/${target}`.replace(/\/+/g, "/");
|
||||
const shouldTryOutbox =
|
||||
!(target.startsWith(".opencode/openwork/outbox/") || target.startsWith("./.opencode/openwork/outbox/")) &&
|
||||
err instanceof OpenworkServerError &&
|
||||
err.status === 404;
|
||||
|
||||
if (!shouldTryOutbox) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
actualPath = candidateOutbox;
|
||||
try {
|
||||
result = (await client.readWorkspaceFile(workspaceId, actualPath)) as OpenworkWorkspaceFileContent;
|
||||
} catch (second) {
|
||||
if (second instanceof OpenworkServerError && second.status === 404) {
|
||||
throw new OpenworkServerError(404, "file_not_found", "File not found (workspace root or outbox).");
|
||||
}
|
||||
throw second;
|
||||
}
|
||||
}
|
||||
|
||||
setOriginal(result.content ?? "");
|
||||
setDraft(result.content ?? "");
|
||||
setLoadedPath(target);
|
||||
setResolvedPath(actualPath);
|
||||
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load file";
|
||||
setError(message);
|
||||
setLoadedPath(target);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (options?: { force?: boolean }) => {
|
||||
const client = props.client;
|
||||
const workspaceId = props.workspaceId;
|
||||
const target = resolvedPath() ?? path();
|
||||
if (!client || !workspaceId || !target) {
|
||||
props.onToast?.("Cannot save: OpenWork server not connected");
|
||||
return;
|
||||
}
|
||||
if (!isMarkdown(target)) {
|
||||
props.onToast?.("Only markdown files are supported");
|
||||
return;
|
||||
}
|
||||
if (!dirty()) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = (await client.writeWorkspaceFile(workspaceId, {
|
||||
path: target,
|
||||
content: draft(),
|
||||
baseUpdatedAt: baseUpdatedAt(),
|
||||
force: options?.force ?? false,
|
||||
})) as OpenworkWorkspaceFileWriteResult;
|
||||
|
||||
setOriginal(draft());
|
||||
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
|
||||
|
||||
if (pendingPath() && pendingReason() === "switch") {
|
||||
const next = pendingPath();
|
||||
setPendingPath(null);
|
||||
setPendingReason(null);
|
||||
if (next) void load(next);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof OpenworkServerError && err.status === 409) {
|
||||
setConfirmOverwrite(true);
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Failed to save";
|
||||
setError(message);
|
||||
props.onToast?.(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (!dirty()) {
|
||||
resetState();
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
setConfirmDiscardClose(true);
|
||||
};
|
||||
|
||||
const requestReload = () => {
|
||||
const target = path();
|
||||
if (!target) return;
|
||||
if (!dirty()) {
|
||||
void load(target);
|
||||
return;
|
||||
}
|
||||
// Reload is destructive; reuse the close-discard banner semantics.
|
||||
setError("Discard changes to reload from disk (close and reopen), or save first.");
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = path();
|
||||
if (!target || loading() || pendingReason() === "switch") return;
|
||||
|
||||
const active = loadedPath();
|
||||
if (!active) {
|
||||
void load(target);
|
||||
return;
|
||||
}
|
||||
if (target === active) return;
|
||||
|
||||
if (!dirty()) {
|
||||
void load(target);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingPath(target);
|
||||
setPendingReason("switch");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
if (canSave()) void save();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<div class="h-14 px-4 border-b border-dls-border flex items-center justify-between bg-dls-sidebar">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<FileText size={16} class="text-dls-secondary shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
|
||||
<Show when={dirty()}>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full border border-amber-7/40 bg-amber-2/30 text-amber-11">
|
||||
Unsaved
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-[11px] text-dls-secondary font-mono truncate" title={path()}>
|
||||
{path()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-9 py-0 px-3"
|
||||
onClick={requestReload}
|
||||
disabled={loading() || saving()}
|
||||
title="Reload from disk"
|
||||
>
|
||||
<RefreshCcw size={14} class={loading() ? "animate-spin" : ""} />
|
||||
Reload
|
||||
</Button>
|
||||
<Button
|
||||
class="text-xs h-9 py-0 px-3"
|
||||
onClick={() => void save()}
|
||||
disabled={!canSave()}
|
||||
title={writeDisabledReason() ?? "Save (Ctrl/Cmd+S)"}
|
||||
>
|
||||
<Save size={14} class={saving() ? "animate-pulse" : ""} />
|
||||
{saving() ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
|
||||
onClick={requestClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={writeDisabledReason()}>
|
||||
{(reason) => (
|
||||
<div class="shrink-0 px-4 py-2 border-b border-dls-border text-[11px] text-dls-secondary">
|
||||
{reason()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
{(message) => (
|
||||
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-red-2/20 text-red-11 text-xs">
|
||||
{message()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={confirmOverwrite()}>
|
||||
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">File changed since load. Overwrite anyway?</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<Button variant="outline" class="text-xs h-8 py-0 px-3" onClick={() => setConfirmOverwrite(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => {
|
||||
setConfirmOverwrite(false);
|
||||
void save({ force: true });
|
||||
}}
|
||||
>
|
||||
Overwrite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={confirmDiscardClose()}>
|
||||
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">Discard unsaved changes and close?</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<Button variant="outline" class="text-xs h-8 py-0 px-3" onClick={() => setConfirmDiscardClose(false)}>
|
||||
Keep
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => {
|
||||
setConfirmDiscardClose(false);
|
||||
resetState();
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={pendingPath() && pendingReason() === "switch"}>
|
||||
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 truncate" title={pendingPath() ?? ""}>
|
||||
Switch to {pendingPath()}
|
||||
</div>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => {
|
||||
setPendingPath(null);
|
||||
setPendingReason(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="text-xs h-8 py-0 px-3"
|
||||
onClick={() => {
|
||||
const next = pendingPath();
|
||||
setPendingPath(null);
|
||||
setPendingReason(null);
|
||||
setOriginal("");
|
||||
setDraft("");
|
||||
if (next) void load(next);
|
||||
}}
|
||||
>
|
||||
Discard & switch
|
||||
</Button>
|
||||
<Button class="text-xs h-8 py-0 px-3" onClick={() => void save()} disabled={!canSave()}>
|
||||
Save & switch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<LiveMarkdownEditor
|
||||
value={draft()}
|
||||
onChange={setDraft}
|
||||
placeholder=""
|
||||
ariaLabel="Artifact editor"
|
||||
class="h-full"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { Paperclip } from "lucide-solid";
|
||||
export type ArtifactsPanelProps = {
|
||||
files: string[];
|
||||
workspaceRoot?: string;
|
||||
onOpenMarkdown?: (path: string) => void;
|
||||
onOpenImage?: (path: string) => void;
|
||||
onRevealArtifact?: (path: string) => void;
|
||||
onOpenInObsidian?: (path: string) => void;
|
||||
obsidianAvailable?: boolean;
|
||||
maxPreview?: number;
|
||||
id?: string;
|
||||
};
|
||||
@@ -88,8 +89,10 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
|
||||
return Math.max(0, total - shown);
|
||||
});
|
||||
|
||||
const canOpenMarkdown = createMemo(() => typeof props.onOpenMarkdown === "function");
|
||||
const canOpenImage = createMemo(() => typeof props.onOpenImage === "function");
|
||||
const canRevealArtifact = createMemo(() => typeof props.onRevealArtifact === "function");
|
||||
const canOpenObsidian = createMemo(
|
||||
() => Boolean(props.obsidianAvailable) && typeof props.onOpenInObsidian === "function",
|
||||
);
|
||||
const prettyPath = (file: string) => toWorkspaceRelative(file, props.workspaceRoot);
|
||||
|
||||
return (
|
||||
@@ -115,25 +118,10 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
|
||||
const dir = () => getDirname(display());
|
||||
const md = () => artifact.kind === "markdown";
|
||||
const img = () => artifact.kind === "image";
|
||||
const openable = () => (md() ? canOpenMarkdown() : img() ? canOpenImage() : false);
|
||||
const tooltip = () => {
|
||||
if (md()) return display();
|
||||
if (img() && !canOpenImage()) return `${display()} (image preview coming soon)`;
|
||||
return display();
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`group w-full flex items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors border border-transparent ${
|
||||
openable() ? "hover:bg-gray-2 hover:border-gray-6/80" : "cursor-default"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (md()) props.onOpenMarkdown?.(artifact.path);
|
||||
else if (img()) props.onOpenImage?.(artifact.path);
|
||||
}}
|
||||
disabled={!openable()}
|
||||
title={tooltip()}
|
||||
aria-label={openable() ? `Open ${display()}` : tooltip()}
|
||||
<div
|
||||
class="group w-full flex items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors border border-transparent hover:bg-gray-2 hover:border-gray-6/80"
|
||||
title={display()}
|
||||
>
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<Paperclip size={12} class="text-gray-9" />
|
||||
@@ -156,7 +144,29 @@ export default function ArtifactsPanel(props: ArtifactsPanelProps) {
|
||||
<div class="truncate text-[11px] text-gray-9">{dir()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<Show when={md() && canOpenObsidian()}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-6 bg-gray-2 px-1.5 py-0.5 text-[10px] font-medium text-gray-10 hover:text-gray-12 hover:border-gray-7 transition-colors"
|
||||
onClick={() => props.onOpenInObsidian?.(artifact.path)}
|
||||
title="Open in Obsidian"
|
||||
>
|
||||
Obsidian
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={canRevealArtifact()}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-6 bg-gray-2 px-1.5 py-0.5 text-[10px] font-medium text-gray-10 hover:text-gray-12 hover:border-gray-7 transition-colors"
|
||||
onClick={() => props.onRevealArtifact?.(artifact.path)}
|
||||
title={img() ? "Reveal image in Finder" : "Reveal file in Finder"}
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, HeartPulse, Loader2, MoreHorizontal, Plus }
|
||||
import type { OpenworkSoulStatus } from "../../lib/openwork-server";
|
||||
import type { WorkspaceInfo } from "../../lib/tauri";
|
||||
import type { WorkspaceSessionGroup } from "../../types";
|
||||
import { formatRelativeTime, getWorkspaceTaskLoadErrorDisplay } from "../../utils";
|
||||
import { formatRelativeTime, getWorkspaceTaskLoadErrorDisplay, isWindowsPlatform } from "../../utils";
|
||||
|
||||
type Props = {
|
||||
workspaceSessionGroups: WorkspaceSessionGroup[];
|
||||
@@ -20,6 +20,7 @@ type Props = {
|
||||
onOpenRenameWorkspace: (workspaceId: string) => void;
|
||||
onShareWorkspace: (workspaceId: string) => void;
|
||||
onOpenSoul: (workspaceId: string) => void;
|
||||
onRevealWorkspace: (workspaceId: string) => void;
|
||||
onTestWorkspaceConnection: (workspaceId: string) => Promise<boolean> | boolean | void;
|
||||
onEditWorkspaceConnection: (workspaceId: string) => void;
|
||||
onForgetWorkspace: (workspaceId: string) => void;
|
||||
@@ -48,6 +49,7 @@ const workspaceKindLabel = (workspace: WorkspaceInfo) =>
|
||||
: "Local";
|
||||
|
||||
export default function WorkspaceSessionList(props: Props) {
|
||||
const revealLabel = isWindowsPlatform() ? "Reveal in Explorer" : "Reveal in Finder";
|
||||
const [expandedWorkspaceIds, setExpandedWorkspaceIds] = createSignal<Set<string>>(new Set());
|
||||
const [previewCountByWorkspaceId, setPreviewCountByWorkspaceId] = createSignal<Record<string, number>>({});
|
||||
const [workspaceMenuId, setWorkspaceMenuId] = createSignal<string | null>(null);
|
||||
@@ -287,6 +289,18 @@ export default function WorkspaceSessionList(props: Props) {
|
||||
>
|
||||
{soulEnabled() ? "Soul settings" : "Enable soul"}
|
||||
</button>
|
||||
<Show when={workspace().workspaceType === "local"}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-gray-3"
|
||||
onClick={() => {
|
||||
props.onRevealWorkspace(workspace().id);
|
||||
setWorkspaceMenuId(null);
|
||||
}}
|
||||
>
|
||||
{revealLabel}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={workspace().workspaceType === "remote"}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -634,6 +634,85 @@ export function createExtensionsStore(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlugin(pluginName: string) {
|
||||
const name = pluginName.trim();
|
||||
if (!name) return;
|
||||
const triggerName = stripPluginVersion(name);
|
||||
|
||||
const isRemoteWorkspace = options.workspaceType() === "remote";
|
||||
const isLocalWorkspace = options.workspaceType() === "local";
|
||||
const openworkClient = options.openworkServerClient();
|
||||
const openworkWorkspaceId = options.openworkServerWorkspaceId();
|
||||
const openworkCapabilities = options.openworkServerCapabilities();
|
||||
const canUseOpenworkServer =
|
||||
options.openworkServerStatus() === "connected" &&
|
||||
openworkClient &&
|
||||
openworkWorkspaceId &&
|
||||
openworkCapabilities?.plugins?.write;
|
||||
|
||||
if (pluginScope() !== "project" && !isLocalWorkspace) {
|
||||
setPluginStatus("Global plugins are only available for local workers.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginScope() === "project" && canUseOpenworkServer) {
|
||||
try {
|
||||
setPluginStatus(null);
|
||||
await openworkClient.removePlugin(openworkWorkspaceId, name);
|
||||
await refreshPlugins("project");
|
||||
} catch (e) {
|
||||
setPluginStatus(e instanceof Error ? e.message : "Failed to remove plugin.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setPluginStatus(translate("skills.plugin_management_host_only"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalWorkspace && !canUseOpenworkServer) {
|
||||
setPluginStatus("OpenWork server unavailable. Connect to manage plugins.");
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = pluginScope();
|
||||
const targetDir = options.projectDir().trim();
|
||||
|
||||
if (scope === "project" && !targetDir) {
|
||||
setPluginStatus(translate("skills.pick_project_for_plugins"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPluginStatus(null);
|
||||
const config = await readOpencodeConfig(scope, targetDir);
|
||||
const raw = config.content ?? "";
|
||||
if (!raw.trim()) {
|
||||
setPluginStatus("No plugins configured yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = parsePluginListFromContent(raw);
|
||||
const desired = stripPluginVersion(name).toLowerCase();
|
||||
const next = plugins.filter((entry) => stripPluginVersion(entry).toLowerCase() !== desired);
|
||||
if (next.length === plugins.length) {
|
||||
setPluginStatus("Plugin not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = modify(raw, ["plugin"], next, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
||||
});
|
||||
const updated = applyEdits(raw, edits);
|
||||
await writeOpencodeConfig(scope, targetDir, updated);
|
||||
options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "removed" });
|
||||
await refreshPlugins(scope);
|
||||
} catch (e) {
|
||||
setPluginStatus(e instanceof Error ? e.message : translate("skills.failed_update_opencode"));
|
||||
}
|
||||
}
|
||||
|
||||
async function importLocalSkill() {
|
||||
const isLocalWorkspace = options.workspaceType() === "local";
|
||||
|
||||
@@ -1045,6 +1124,7 @@ export function createExtensionsStore(options: {
|
||||
refreshHubSkills,
|
||||
refreshPlugins,
|
||||
addPlugin,
|
||||
removePlugin,
|
||||
importLocalSkill,
|
||||
installSkillCreator,
|
||||
installHubSkill,
|
||||
|
||||
@@ -667,6 +667,18 @@ export async function resetOpencodeCache(): Promise<CacheResetResult> {
|
||||
return invoke<CacheResetResult>("reset_opencode_cache");
|
||||
}
|
||||
|
||||
export async function obsidianIsAvailable(): Promise<boolean> {
|
||||
return invoke<boolean>("obsidian_is_available");
|
||||
}
|
||||
|
||||
export async function openInObsidian(filePath: string): Promise<void> {
|
||||
const safePath = filePath.trim();
|
||||
if (!safePath) {
|
||||
throw new Error("filePath is required");
|
||||
}
|
||||
return invoke<void>("open_in_obsidian", { filePath: safePath });
|
||||
}
|
||||
|
||||
export async function schedulerListJobs(scopeRoot?: string): Promise<ScheduledJob[]> {
|
||||
return invoke<ScheduledJob[]>("scheduler_list_jobs", { scopeRoot });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatRelativeTime,
|
||||
getWorkspaceTaskLoadErrorDisplay,
|
||||
isTauriRuntime,
|
||||
isWindowsPlatform,
|
||||
normalizeDirectoryPath,
|
||||
} from "../utils";
|
||||
import {
|
||||
@@ -210,6 +211,7 @@ export type DashboardViewProps = {
|
||||
}>;
|
||||
}>;
|
||||
addPlugin: (pluginNameOverride?: string) => void;
|
||||
removePlugin: (pluginName: string) => void;
|
||||
mcpServers: McpServerEntry[];
|
||||
mcpStatus: string | null;
|
||||
mcpLastUpdatedAt: number | null;
|
||||
@@ -526,6 +528,23 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
})();
|
||||
};
|
||||
|
||||
const revealWorkspaceInFinder = async (workspaceId: string) => {
|
||||
const workspace = props.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace || workspace.workspaceType !== "local") return;
|
||||
const target = workspace.path?.trim() ?? "";
|
||||
if (!target || !isTauriRuntime()) return;
|
||||
try {
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(target);
|
||||
} else {
|
||||
await revealItemInDir(target);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to reveal workspace", error);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.developerMode) return;
|
||||
if (props.tab !== "config") return;
|
||||
@@ -1052,6 +1071,7 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
onOpenRenameWorkspace={props.openRenameWorkspace}
|
||||
onShareWorkspace={(workspaceId) => setShareWorkspaceId(workspaceId)}
|
||||
onOpenSoul={openSoulForWorkspace}
|
||||
onRevealWorkspace={revealWorkspaceInFinder}
|
||||
onTestWorkspaceConnection={props.testWorkspaceConnection}
|
||||
onEditWorkspaceConnection={props.editWorkspaceConnection}
|
||||
onForgetWorkspace={props.forgetWorkspace}
|
||||
@@ -1215,6 +1235,7 @@ export default function DashboardView(props: DashboardViewProps) {
|
||||
suggestedPlugins={props.suggestedPlugins}
|
||||
refreshPlugins={props.refreshPlugins}
|
||||
addPlugin={props.addPlugin}
|
||||
removePlugin={props.removePlugin}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ export default function ExtensionsView(props: ExtensionsViewProps) {
|
||||
suggestedPlugins={props.suggestedPlugins}
|
||||
refreshPlugins={props.refreshPlugins}
|
||||
addPlugin={props.addPlugin}
|
||||
removePlugin={props.removePlugin}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -40,6 +40,7 @@ export type PluginsViewProps = {
|
||||
}>;
|
||||
refreshPlugins: (scopeOverride?: PluginScope) => void;
|
||||
addPlugin: (pluginNameOverride?: string) => void;
|
||||
removePlugin: (pluginName: string) => void;
|
||||
};
|
||||
|
||||
export default function PluginsView(props: PluginsViewProps) {
|
||||
@@ -197,7 +198,17 @@ export default function PluginsView(props: PluginsViewProps) {
|
||||
{(pluginName) => (
|
||||
<div class="flex items-center justify-between rounded-xl border border-gray-6/60 bg-gray-1/40 px-4 py-2.5">
|
||||
<div class="text-sm text-gray-12 font-mono">{pluginName}</div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-10">Enabled</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-10">Enabled</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[11px] text-red-11 hover:text-red-12"
|
||||
onClick={() => props.removePlugin(pluginName)}
|
||||
disabled={props.busy || !props.canEditPlugins}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -20,7 +20,13 @@ import type {
|
||||
WorkspaceSessionGroup,
|
||||
} from "../types";
|
||||
|
||||
import type { EngineInfo, OpenworkServerInfo, WorkspaceInfo } from "../lib/tauri";
|
||||
import {
|
||||
obsidianIsAvailable,
|
||||
openInObsidian,
|
||||
type EngineInfo,
|
||||
type OpenworkServerInfo,
|
||||
type WorkspaceInfo,
|
||||
} from "../lib/tauri";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -69,6 +75,7 @@ import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL, publishOpenworkBundleJson } from "
|
||||
import { join } from "@tauri-apps/api/path";
|
||||
import {
|
||||
isTauriRuntime,
|
||||
isWindowsPlatform,
|
||||
normalizeDirectoryPath,
|
||||
parseTemplateFrontmatter,
|
||||
} from "../utils";
|
||||
@@ -85,7 +92,6 @@ import FlyoutItem from "../components/flyout-item";
|
||||
import QuestionModal from "../components/question-modal";
|
||||
import ArtifactsPanel from "../components/session/artifacts-panel";
|
||||
import InboxPanel from "../components/session/inbox-panel";
|
||||
import ArtifactMarkdownEditor from "../components/session/artifact-markdown-editor";
|
||||
|
||||
export type SessionViewProps = {
|
||||
selectedSessionId: string | null;
|
||||
@@ -318,8 +324,7 @@ export default function SessionView(props: SessionViewProps) {
|
||||
const [messageWindowSessionId, setMessageWindowSessionId] = createSignal<string | null>(null);
|
||||
const [messageWindowExpanded, setMessageWindowExpanded] = createSignal(false);
|
||||
|
||||
const [markdownEditorOpen, setMarkdownEditorOpen] = createSignal(false);
|
||||
const [markdownEditorPath, setMarkdownEditorPath] = createSignal<string | null>(null);
|
||||
const [obsidianAvailable, setObsidianAvailable] = createSignal(false);
|
||||
|
||||
// When a session is selected (i.e. we are in SessionView), the right sidebar is
|
||||
// navigation-only. Avoid showing any tab as "selected" to reduce confusion.
|
||||
@@ -327,6 +332,25 @@ export default function SessionView(props: SessionViewProps) {
|
||||
let commandPaletteInputEl: HTMLInputElement | undefined;
|
||||
const commandPaletteOptionRefs: HTMLButtonElement[] = [];
|
||||
|
||||
createEffect(() => {
|
||||
if (!isTauriRuntime()) {
|
||||
setObsidianAvailable(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const available = await obsidianIsAvailable();
|
||||
if (!cancelled) setObsidianAvailable(available);
|
||||
} catch {
|
||||
if (!cancelled) setObsidianAvailable(false);
|
||||
}
|
||||
})();
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
|
||||
const agentLabel = createMemo(() => props.selectedSessionAgent ?? "Default agent");
|
||||
const workspaceLabel = (workspace: WorkspaceInfo) =>
|
||||
workspace.displayName?.trim() ||
|
||||
@@ -730,86 +754,91 @@ export default function SessionView(props: SessionViewProps) {
|
||||
return out;
|
||||
});
|
||||
|
||||
const normalizeSidebarPath = (value: string) => String(value ?? "").trim().replace(/[\\/]+/g, "/");
|
||||
const resolveArtifactLocalPath = async (file: string) => {
|
||||
const trimmed = file.trim();
|
||||
if (!trimmed) return null;
|
||||
const root = props.activeWorkspaceRoot.trim();
|
||||
if (!isAbsolutePath(trimmed) && !root) return null;
|
||||
return !isAbsolutePath(trimmed) && root ? await join(root, trimmed) : trimmed;
|
||||
};
|
||||
|
||||
const toWorkspaceRelativeForApi = (file: string) => {
|
||||
const normalized = normalizeSidebarPath(file).replace(/^file:\/\//i, "");
|
||||
if (!normalized) return "";
|
||||
|
||||
const root = normalizeSidebarPath(props.activeWorkspaceRoot).replace(/\/+$/, "");
|
||||
const rootKey = root.toLowerCase();
|
||||
const fileKey = normalized.toLowerCase();
|
||||
|
||||
if (root && fileKey.startsWith(`${rootKey}/`)) {
|
||||
return normalized.slice(root.length + 1);
|
||||
const revealArtifact = async (file: string) => {
|
||||
if (props.activeWorkspaceDisplay.workspaceType === "remote") {
|
||||
setToastMessage("Reveal is unavailable for remote workers.");
|
||||
return;
|
||||
}
|
||||
if (root && fileKey === rootKey) {
|
||||
return "";
|
||||
if (!isTauriRuntime()) {
|
||||
setToastMessage("Reveal is available in the desktop app.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (root) {
|
||||
const rootSegments = root.split("/").filter(Boolean);
|
||||
const workspaceFolderName = rootSegments[rootSegments.length - 1]?.toLowerCase();
|
||||
if (workspaceFolderName) {
|
||||
const workspaceMarker = `workspaces/${workspaceFolderName}/`;
|
||||
const markerIndex = fileKey.indexOf(workspaceMarker);
|
||||
if (markerIndex >= 0) return normalized.slice(markerIndex + workspaceMarker.length);
|
||||
if (fileKey.endsWith(`workspaces/${workspaceFolderName}`)) return "";
|
||||
try {
|
||||
const target = await resolveArtifactLocalPath(file);
|
||||
if (!target) {
|
||||
setToastMessage("Pick a worker to reveal files.");
|
||||
return;
|
||||
}
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(target);
|
||||
} else {
|
||||
await revealItemInDir(target);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to reveal file";
|
||||
setToastMessage(message);
|
||||
}
|
||||
|
||||
let relative = normalized.replace(/^\.\/+/, "");
|
||||
if (!relative) return "";
|
||||
|
||||
// Tool output paths sometimes carry git-style prefixes (a/ or b/).
|
||||
if (/^[ab]\/.+\.(md|mdx|markdown)$/i.test(relative)) {
|
||||
relative = relative.slice(2);
|
||||
}
|
||||
|
||||
// Some tool outputs include a leading "workspace/" prefix.
|
||||
if (/^workspace\//i.test(relative)) {
|
||||
relative = relative.replace(/^workspace\//i, "");
|
||||
}
|
||||
|
||||
// Other surfaces include an absolute-style "/workspace/<path>" prefix.
|
||||
if (/^\/+workspace\//i.test(relative)) {
|
||||
relative = relative.replace(/^\/+workspace\//i, "");
|
||||
}
|
||||
|
||||
if (relative.startsWith("/") || relative.startsWith("~") || /^[a-zA-Z]:\//.test(relative)) return "";
|
||||
if (relative.split("/").some((part) => part === "." || part === "..")) return "";
|
||||
|
||||
if (/com\.[^/]+\.(openwork|opencode)/i.test(relative)) return "";
|
||||
|
||||
return relative;
|
||||
};
|
||||
|
||||
const openMarkdownEditor = (file: string) => {
|
||||
if (!props.openworkServerClient) {
|
||||
setToastMessage("Cannot open file: not connected to OpenWork server.");
|
||||
const openArtifactInObsidian = async (file: string) => {
|
||||
if (!/\.(md|mdx|markdown)$/i.test(file)) return;
|
||||
if (!obsidianAvailable()) {
|
||||
setToastMessage("Obsidian is not available on this system.");
|
||||
return;
|
||||
}
|
||||
if (!props.openworkServerWorkspaceId) {
|
||||
setToastMessage("Cannot open file: no workspace selected.");
|
||||
if (props.activeWorkspaceDisplay.workspaceType === "remote") {
|
||||
setToastMessage("Open in Obsidian is unavailable for remote workers.");
|
||||
return;
|
||||
}
|
||||
|
||||
const relative = toWorkspaceRelativeForApi(file);
|
||||
if (!relative) {
|
||||
setToastMessage(`Cannot open file: path "${file}" is not within the workspace.`);
|
||||
if (!isTauriRuntime()) {
|
||||
setToastMessage("Open in Obsidian is available in the desktop app.");
|
||||
return;
|
||||
}
|
||||
if (!/\.(md|mdx|markdown)$/i.test(relative)) {
|
||||
setToastMessage("Only markdown files can be edited here right now.");
|
||||
return;
|
||||
try {
|
||||
const target = await resolveArtifactLocalPath(file);
|
||||
if (!target) {
|
||||
setToastMessage("Pick a worker to open files.");
|
||||
return;
|
||||
}
|
||||
await openInObsidian(target);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to open file in Obsidian";
|
||||
setToastMessage(message);
|
||||
}
|
||||
setMarkdownEditorPath(relative);
|
||||
setMarkdownEditorOpen(true);
|
||||
};
|
||||
|
||||
const closeMarkdownEditor = () => {
|
||||
setMarkdownEditorOpen(false);
|
||||
setMarkdownEditorPath(null);
|
||||
const revealWorkspaceInFinder = async (workspaceId: string) => {
|
||||
const workspace = props.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace || workspace.workspaceType !== "local") return;
|
||||
const target = workspace.path?.trim() ?? "";
|
||||
if (!target) {
|
||||
setToastMessage("Workspace path is unavailable.");
|
||||
return;
|
||||
}
|
||||
if (!isTauriRuntime()) {
|
||||
setToastMessage("Reveal is available in the desktop app.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { openPath, revealItemInDir } = await import("@tauri-apps/plugin-opener");
|
||||
if (isWindowsPlatform()) {
|
||||
await openPath(target);
|
||||
} else {
|
||||
await revealItemInDir(target);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to reveal workspace";
|
||||
setToastMessage(message);
|
||||
}
|
||||
};
|
||||
const todoLabel = createMemo(() => {
|
||||
const total = todoCount();
|
||||
@@ -2687,6 +2716,7 @@ export default function SessionView(props: SessionViewProps) {
|
||||
onOpenRenameWorkspace={props.openRenameWorkspace}
|
||||
onShareWorkspace={(workspaceId) => setShareWorkspaceId(workspaceId)}
|
||||
onOpenSoul={openSoul}
|
||||
onRevealWorkspace={revealWorkspaceInFinder}
|
||||
onTestWorkspaceConnection={props.testWorkspaceConnection}
|
||||
onEditWorkspaceConnection={props.editWorkspaceConnection}
|
||||
onForgetWorkspace={props.forgetWorkspace}
|
||||
@@ -3049,18 +3079,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={markdownEditorOpen()}>
|
||||
<aside class="hidden lg:flex w-[50%] min-w-[450px] shrink-0 border-l border-gray-6/70 bg-gray-1 shadow-[-8px_0_32px_rgba(0,0,0,0.03)]">
|
||||
<ArtifactMarkdownEditor
|
||||
open={markdownEditorOpen()}
|
||||
path={markdownEditorPath()}
|
||||
workspaceId={props.openworkServerWorkspaceId}
|
||||
client={props.openworkServerClient}
|
||||
onClose={closeMarkdownEditor}
|
||||
onToast={(message) => setToastMessage(message)}
|
||||
/>
|
||||
</aside>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={todoCount() > 0}>
|
||||
@@ -3284,7 +3302,9 @@ export default function SessionView(props: SessionViewProps) {
|
||||
id="sidebar-artifacts"
|
||||
files={touchedFiles()}
|
||||
workspaceRoot={props.activeWorkspaceRoot}
|
||||
onOpenMarkdown={openMarkdownEditor}
|
||||
onRevealArtifact={revealArtifact}
|
||||
onOpenInObsidian={openArtifactInObsidian}
|
||||
obsidianAvailable={obsidianAvailable()}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -271,6 +271,62 @@ pub fn app_build_info(app: AppHandle) -> AppBuildInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn obsidian_is_available() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut candidates = vec![PathBuf::from("/Applications/Obsidian.app")];
|
||||
if let Some(home) = home_dir() {
|
||||
candidates.push(home.join("Applications").join("Obsidian.app"));
|
||||
}
|
||||
return candidates.into_iter().any(|path| path.exists());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_in_obsidian(file_path: String) -> Result<(), String> {
|
||||
let trimmed = file_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("file_path is required".to_string());
|
||||
}
|
||||
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !path.is_absolute() {
|
||||
return Err("file_path must be an absolute path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !obsidian_is_available() {
|
||||
return Err("Obsidian is not installed.".to_string());
|
||||
}
|
||||
|
||||
let status = std::process::Command::new("open")
|
||||
.arg("-a")
|
||||
.arg("Obsidian")
|
||||
.arg(&path)
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to launch Obsidian: {e}"))?;
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to launch Obsidian (exit status: {status})."));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Err("Open in Obsidian is currently supported on macOS only.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn opencode_db_migrate(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -22,8 +22,8 @@ use commands::command_files::{
|
||||
use commands::config::{read_opencode_config, write_opencode_config};
|
||||
use commands::engine::{engine_doctor, engine_info, engine_install, engine_start, engine_stop};
|
||||
use commands::misc::{
|
||||
app_build_info, opencode_db_migrate, opencode_mcp_auth, reset_opencode_cache,
|
||||
reset_openwork_state,
|
||||
app_build_info, obsidian_is_available, open_in_obsidian, opencode_db_migrate, opencode_mcp_auth,
|
||||
reset_opencode_cache, reset_openwork_state,
|
||||
};
|
||||
use commands::opencode_router::{
|
||||
opencodeRouter_config_set, opencodeRouter_info, opencodeRouter_start, opencodeRouter_status,
|
||||
@@ -134,6 +134,8 @@ pub fn run() {
|
||||
write_opencode_config,
|
||||
updater_environment,
|
||||
app_build_info,
|
||||
obsidian_is_available,
|
||||
open_in_obsidian,
|
||||
reset_openwork_state,
|
||||
reset_opencode_cache,
|
||||
opencode_db_migrate,
|
||||
|
||||
Reference in New Issue
Block a user