Skip to content

Commit 9b4f696

Browse files
authored
show apps icon in the widgets bar which shows a tsunami app launcher (#2554)
1 parent 347eef2 commit 9b4f696

File tree

8 files changed

+245
-22
lines changed

8 files changed

+245
-22
lines changed

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,11 @@ class RpcApiType {
362362
return client.wshRpcCall("listallappfiles", data, opts);
363363
}
364364

365+
// command "listallapps" [call]
366+
ListAllAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> {
367+
return client.wshRpcCall("listallapps", null, opts);
368+
}
369+
365370
// command "listalleditableapps" [call]
366371
ListAllEditableAppsCommand(client: WshClient, opts?: RpcOpts): Promise<AppInfo[]> {
367372
return client.wshRpcCall("listalleditableapps", null, opts);

frontend/app/workspace/widgets.tsx

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import { RpcApi } from "@/app/store/wshclientapi";
77
import { TabRpcClient } from "@/app/store/wshrpcutil";
88
import { atoms, createBlock, getApi, isDev } from "@/store/global";
99
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
10+
import {
11+
FloatingPortal,
12+
autoUpdate,
13+
offset,
14+
shift,
15+
useDismiss,
16+
useFloating,
17+
useInteractions,
18+
} from "@floating-ui/react";
1019
import clsx from "clsx";
1120
import { useAtomValue } from "jotai";
1221
import { memo, useCallback, useEffect, useRef, useState } from "react";
@@ -67,6 +76,132 @@ const Widget = memo(({ widget, mode }: { widget: WidgetConfigType; mode: "normal
6776
);
6877
});
6978

79+
function calculateGridSize(appCount: number): number {
80+
if (appCount <= 4) return 2;
81+
if (appCount <= 9) return 3;
82+
if (appCount <= 16) return 4;
83+
if (appCount <= 25) return 5;
84+
return 6;
85+
}
86+
87+
const AppsFloatingWindow = memo(
88+
({
89+
isOpen,
90+
onClose,
91+
referenceElement,
92+
}: {
93+
isOpen: boolean;
94+
onClose: () => void;
95+
referenceElement: HTMLElement;
96+
}) => {
97+
const [apps, setApps] = useState<AppInfo[]>([]);
98+
const [loading, setLoading] = useState(true);
99+
100+
const { refs, floatingStyles, context } = useFloating({
101+
open: isOpen,
102+
onOpenChange: onClose,
103+
placement: "left-start",
104+
middleware: [offset(-2), shift({ padding: 12 })],
105+
whileElementsMounted: autoUpdate,
106+
elements: {
107+
reference: referenceElement,
108+
},
109+
});
110+
111+
const dismiss = useDismiss(context);
112+
const { getFloatingProps } = useInteractions([dismiss]);
113+
114+
useEffect(() => {
115+
if (!isOpen) return;
116+
117+
const fetchApps = async () => {
118+
setLoading(true);
119+
try {
120+
const allApps = await RpcApi.ListAllAppsCommand(TabRpcClient);
121+
const localApps = allApps
122+
.filter((app) => !app.appid.startsWith("draft/"))
123+
.sort((a, b) => {
124+
const aName = a.appid.replace(/^local\//, "");
125+
const bName = b.appid.replace(/^local\//, "");
126+
return aName.localeCompare(bName);
127+
});
128+
setApps(localApps);
129+
} catch (error) {
130+
console.error("Failed to fetch apps:", error);
131+
setApps([]);
132+
} finally {
133+
setLoading(false);
134+
}
135+
};
136+
137+
fetchApps();
138+
}, [isOpen]);
139+
140+
if (!isOpen) return null;
141+
142+
const gridSize = calculateGridSize(apps.length);
143+
144+
return (
145+
<FloatingPortal>
146+
<div
147+
ref={refs.setFloating}
148+
style={floatingStyles}
149+
{...getFloatingProps()}
150+
className="bg-modalbg border border-border rounded-lg shadow-xl p-4 z-50"
151+
>
152+
{loading ? (
153+
<div className="flex items-center justify-center p-8">
154+
<i className="fa fa-solid fa-spinner fa-spin text-2xl text-muted"></i>
155+
</div>
156+
) : apps.length === 0 ? (
157+
<div className="text-muted text-sm p-4 text-center">No local apps found</div>
158+
) : (
159+
<div
160+
className="grid gap-3"
161+
style={{
162+
gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,
163+
maxWidth: `${gridSize * 80}px`,
164+
}}
165+
>
166+
{apps.map((app) => {
167+
const appMeta = app.manifest?.appmeta;
168+
const displayName = app.appid.replace(/^local\//, "");
169+
const icon = appMeta?.icon || "cube";
170+
const iconColor = appMeta?.iconcolor || "white";
171+
172+
return (
173+
<div
174+
key={app.appid}
175+
className="flex flex-col items-center justify-center p-2 rounded hover:bg-hoverbg cursor-pointer transition-colors"
176+
onClick={() => {
177+
const blockDef: BlockDef = {
178+
meta: {
179+
view: "tsunami",
180+
controller: "tsunami",
181+
"tsunami:appid": app.appid,
182+
},
183+
};
184+
createBlock(blockDef);
185+
onClose();
186+
}}
187+
>
188+
<div style={{ color: iconColor }} className="text-3xl mb-1">
189+
<i className={makeIconClass(icon, false)}></i>
190+
</div>
191+
<div className="text-xxs text-center text-secondary break-words w-full px-1">
192+
{displayName}
193+
</div>
194+
</div>
195+
);
196+
})}
197+
</div>
198+
)}
199+
</div>
200+
</FloatingPortal>
201+
);
202+
}
203+
);
204+
70205
const Widgets = memo(() => {
71206
const fullConfig = useAtomValue(atoms.fullConfigAtom);
72207
const hasCustomAIPresets = useAtomValue(atoms.hasCustomAIPresetsAtom);
@@ -100,6 +235,9 @@ const Widgets = memo(() => {
100235
: Object.fromEntries(Object.entries(widgetsMap).filter(([key]) => key !== "defwidget@ai"));
101236
const widgets = sortByDisplayOrder(filteredWidgets);
102237

238+
const [isAppsOpen, setIsAppsOpen] = useState(false);
239+
const appsButtonRef = useRef<HTMLDivElement>(null);
240+
103241
const checkModeNeeded = useCallback(() => {
104242
if (!containerRef.current || !measurementRef.current) return;
105243

@@ -204,10 +342,27 @@ const Widgets = memo(() => {
204342
))}
205343
</div>
206344
<div className="flex-grow" />
207-
{showHelp ? (
345+
{isDev() || showHelp ? (
208346
<div className="grid grid-cols-2 gap-0 w-full">
209-
<Widget key="tips" widget={tipsWidget} mode={mode} />
210-
<Widget key="help" widget={helpWidget} mode={mode} />
347+
{isDev() ? (
348+
<div
349+
ref={appsButtonRef}
350+
className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer"
351+
onClick={() => setIsAppsOpen(!isAppsOpen)}
352+
>
353+
<Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}>
354+
<div>
355+
<i className={makeIconClass("cube", true)}></i>
356+
</div>
357+
</Tooltip>
358+
</div>
359+
) : null}
360+
{showHelp ? (
361+
<>
362+
<Widget key="tips" widget={tipsWidget} mode={mode} />
363+
<Widget key="help" widget={helpWidget} mode={mode} />
364+
</>
365+
) : null}
211366
</div>
212367
) : null}
213368
</>
@@ -217,6 +372,24 @@ const Widgets = memo(() => {
217372
<Widget key={`widget-${idx}`} widget={data} mode={mode} />
218373
))}
219374
<div className="flex-grow" />
375+
{isDev() ? (
376+
<div
377+
ref={appsButtonRef}
378+
className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer"
379+
onClick={() => setIsAppsOpen(!isAppsOpen)}
380+
>
381+
<Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}>
382+
<div>
383+
<i className={makeIconClass("cube", true)}></i>
384+
</div>
385+
{mode === "normal" && (
386+
<div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis">
387+
apps
388+
</div>
389+
)}
390+
</Tooltip>
391+
</div>
392+
) : null}
220393
{showHelp ? (
221394
<>
222395
<Widget key="tips" widget={tipsWidget} mode={mode} />
@@ -234,6 +407,13 @@ const Widgets = memo(() => {
234407
</div>
235408
) : null}
236409
</div>
410+
{isDev() && appsButtonRef.current && (
411+
<AppsFloatingWindow
412+
isOpen={isAppsOpen}
413+
onClose={() => setIsAppsOpen(false)}
414+
referenceElement={appsButtonRef.current}
415+
/>
416+
)}
237417

238418
<div
239419
ref={measurementRef}
@@ -249,6 +429,14 @@ const Widgets = memo(() => {
249429
<Widget key="measurement-help" widget={helpWidget} mode="normal" />
250430
</>
251431
) : null}
432+
{isDev() ? (
433+
<div className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg">
434+
<div>
435+
<i className={makeIconClass("cube", true)}></i>
436+
</div>
437+
<div className="text-xxs mt-0.5 w-full px-0.5 text-center">apps</div>
438+
</div>
439+
) : null}
252440
{isDev() ? (
253441
<div
254442
className="dev-label flex justify-center items-center w-full py-1 text-accent text-[30px]"

frontend/types/gotypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ declare global {
6060
type AppInfo = {
6161
appid: string;
6262
modtime: number;
63+
manifest?: AppManifest;
6364
};
6465

6566
// wshrpc.AppManifest

pkg/waveappstore/waveappstore.go

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/wavetermdev/waveterm/pkg/waveapputil"
1818
"github.com/wavetermdev/waveterm/pkg/wavebase"
1919
"github.com/wavetermdev/waveterm/pkg/wshrpc"
20-
"github.com/wavetermdev/waveterm/tsunami/engine"
2120
)
2221

2322
const (
@@ -456,20 +455,20 @@ func ListAllAppFiles(appId string) (*fileutil.ReadDirResult, error) {
456455
return fileutil.ReadDirRecursive(appDir, 10000)
457456
}
458457

459-
func ListAllApps() ([]string, error) {
458+
func ListAllApps() ([]wshrpc.AppInfo, error) {
460459
homeDir := wavebase.GetHomeDir()
461460
waveappsDir := filepath.Join(homeDir, "waveapps")
462461

463462
if _, err := os.Stat(waveappsDir); os.IsNotExist(err) {
464-
return []string{}, nil
463+
return []wshrpc.AppInfo{}, nil
465464
}
466465

467466
namespaces, err := os.ReadDir(waveappsDir)
468467
if err != nil {
469468
return nil, fmt.Errorf("failed to read waveapps directory: %w", err)
470469
}
471470

472-
var appIds []string
471+
var appInfos []wshrpc.AppInfo
473472

474473
for _, ns := range namespaces {
475474
if !ns.IsDir() {
@@ -493,13 +492,24 @@ func ListAllApps() ([]string, error) {
493492
appId := MakeAppId(namespace, appName)
494493

495494
if err := ValidateAppId(appId); err == nil {
496-
appIds = append(appIds, appId)
495+
modTime, _ := GetAppModTime(appId)
496+
appInfo := wshrpc.AppInfo{
497+
AppId: appId,
498+
ModTime: modTime,
499+
}
500+
501+
if manifest, err := ReadAppManifest(appId); err == nil {
502+
appInfo.Manifest = manifest
503+
}
504+
505+
appInfos = append(appInfos, appInfo)
497506
}
498507
}
499508
}
500509

501-
return appIds, nil
510+
return appInfos, nil
502511
}
512+
503513
func GetAppModTime(appId string) (int64, error) {
504514
if err := ValidateAppId(appId); err != nil {
505515
return 0, err
@@ -575,25 +585,31 @@ func ListAllEditableApps() ([]wshrpc.AppInfo, error) {
575585
var appInfos []wshrpc.AppInfo
576586
for appName := range allAppNames {
577587
var appId string
578-
var modTimeAppId string
588+
var manifestAppId string
579589
if localApps[appName] {
580590
appId = MakeAppId(AppNSLocal, appName)
581591
} else {
582592
appId = MakeAppId(AppNSDraft, appName)
583593
}
584594

585595
if draftApps[appName] {
586-
modTimeAppId = MakeAppId(AppNSDraft, appName)
596+
manifestAppId = MakeAppId(AppNSDraft, appName)
587597
} else {
588-
modTimeAppId = appId
598+
manifestAppId = appId
589599
}
590600

591-
modTime, _ := GetAppModTime(modTimeAppId)
601+
modTime, _ := GetAppModTime(manifestAppId)
592602

593-
appInfos = append(appInfos, wshrpc.AppInfo{
603+
appInfo := wshrpc.AppInfo{
594604
AppId: appId,
595605
ModTime: modTime,
596-
})
606+
}
607+
608+
if manifest, err := ReadAppManifest(manifestAppId); err == nil {
609+
appInfo.Manifest = manifest
610+
}
611+
612+
appInfos = append(appInfos, appInfo)
597613
}
598614

599615
return appInfos, nil
@@ -703,7 +719,7 @@ func RenameLocalApp(appName string, newAppName string) error {
703719
return nil
704720
}
705721

706-
func ReadAppManifest(appId string) (*engine.AppManifest, error) {
722+
func ReadAppManifest(appId string) (*wshrpc.AppManifest, error) {
707723
if err := ValidateAppId(appId); err != nil {
708724
return nil, fmt.Errorf("invalid appId: %w", err)
709725
}
@@ -719,7 +735,7 @@ func ReadAppManifest(appId string) (*engine.AppManifest, error) {
719735
return nil, fmt.Errorf("failed to read %s: %w", ManifestFileName, err)
720736
}
721737

722-
var manifest engine.AppManifest
738+
var manifest wshrpc.AppManifest
723739
if err := json.Unmarshal(data, &manifest); err != nil {
724740
return nil, fmt.Errorf("failed to parse %s: %w", ManifestFileName, err)
725741
}
@@ -785,7 +801,7 @@ func WriteAppSecretBindings(appId string, bindings map[string]string) error {
785801
return nil
786802
}
787803

788-
func BuildAppSecretEnv(appId string, manifest *engine.AppManifest, bindings map[string]string) (map[string]string, error) {
804+
func BuildAppSecretEnv(appId string, manifest *wshrpc.AppManifest, bindings map[string]string) (map[string]string, error) {
789805
if manifest == nil {
790806
return make(map[string]string), nil
791807
}

0 commit comments

Comments
 (0)