Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions db/migrations-wstore/000012_tabtemplate.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS db_tabtemplate;
5 changes: 5 additions & 0 deletions db/migrations-wstore/000012_tabtemplate.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS db_tabtemplate (
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
1 change: 1 addition & 0 deletions electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const config = {
singleArchFiles: "**/dist/bin/wavesrv.*",
entitlements: "build/entitlements.mac.plist",
entitlementsInherit: "build/entitlements.mac.plist",
notarize: !!process.env.APPLE_TEAM_ID,
extendInfo: {
NSContactsUsageDescription: "A CLI application running in Wave wants to use your contacts.",
NSRemindersUsageDescription: "A CLI application running in Wave wants to use your reminders.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/modals/modalregistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import { MessageModal } from "@/app/modals/messagemodal";
import { SaveTemplateModal } from "@/app/modals/savetemplatemodal";
import { TemplateManagerModal } from "@/app/modals/templatemanagermodal";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch";
Expand All @@ -21,6 +23,8 @@ const modalRegistry: { [key: string]: React.ComponentType<any> } = {
[RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal,
[DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal,
[SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog,
[SaveTemplateModal.displayName || "SaveTemplateModal"]: SaveTemplateModal,
[TemplateManagerModal.displayName || "TemplateManagerModal"]: TemplateManagerModal,
};

export const getModalComponent = (key: string): React.ComponentType<any> | undefined => {
Expand Down
92 changes: 92 additions & 0 deletions frontend/app/modals/savetemplatemodal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { Modal } from "@/app/modals/modal";
import { modalsModel } from "@/store/modalmodel";
import * as keyutil from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
import { useCallback, useState } from "react";
import { TabTemplateService } from "../store/services";

interface SaveTemplateModalProps {
tabId: string;
}

const SaveTemplateModal = ({ tabId }: SaveTemplateModalProps) => {
const [templateName, setTemplateName] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleClose = useCallback(() => {
if (saving) return;
modalsModel.popModal();
}, [saving]);

const handleSave = useCallback(() => {
if (saving) return;
if (!templateName.trim()) {
setError("Please enter a template name");
return;
}
setSaving(true);
setError(null);
fireAndForget(async () => {
try {
await TabTemplateService.SaveTabAsTemplate(tabId, templateName.trim());
modalsModel.popModal();
} catch (e) {
setError(e.message || "Failed to save template");
setSaving(false);
}
});
}, [tabId, templateName]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleKeyDown = useCallback(
(waveEvent: WaveKeyboardEvent): boolean => {
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
handleClose();
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
handleSave();
return true;
}
return false;
},
[handleClose, handleSave]
);

return (
<Modal
className="pt-6 pb-4 px-5"
onOk={handleSave}
onCancel={handleClose}
onClose={handleClose}
okLabel={saving ? "Saving..." : "Save"}
cancelLabel="Cancel"
okDisabled={saving || !templateName.trim()}
>
<div className="font-bold text-primary mx-4 pb-2.5">Save Tab as Template</div>
<div className="flex flex-col gap-4 mx-4 mb-4 max-w-[400px]">
<div className="text-secondary text-sm">
Save this tab's layout and block configuration as a reusable template.
</div>
<input
type="text"
placeholder="Template name"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className="resize-none bg-panel rounded-md border border-border py-1.5 pl-4 min-h-[30px] text-inherit cursor-text focus:ring-2 focus:ring-accent focus:outline-none"
autoFocus
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
disabled={saving}
/>
{error && <div className="text-red-500 text-sm">{error}</div>}
</div>
</Modal>
);
};

SaveTemplateModal.displayName = "SaveTemplateModal";

export { SaveTemplateModal };
180 changes: 180 additions & 0 deletions frontend/app/modals/templatemanagermodal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { Button } from "@/app/element/button";
import { Modal } from "@/app/modals/modal";
import { modalsModel } from "@/store/modalmodel";
import * as keyutil from "@/util/keyutil";
import { fireAndForget } from "@/util/util";
import { useCallback, useEffect, useState } from "react";
import { TabTemplateService } from "../store/services";

const TemplateManagerModal = () => {
const [templates, setTemplates] = useState<TabTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);

const loadTemplates = useCallback(async () => {
try {
const result = await TabTemplateService.ListTabTemplates();
setTemplates(result || []);
} catch (e) {
console.error("Failed to load templates:", e);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
loadTemplates();
}, [loadTemplates]);

const handleClose = useCallback(() => {
modalsModel.popModal();
}, []);

const handleStartEdit = useCallback((template: TabTemplate) => {
setEditingId(template.oid);
setEditingName(template.name);
setDeleteConfirmId(null);
}, []);

const handleSaveEdit = useCallback(async () => {
if (!editingId || !editingName.trim()) return;
try {
await TabTemplateService.UpdateTabTemplate(editingId, editingName.trim());
setEditingId(null);
setEditingName("");
loadTemplates();
} catch (e) {
console.error("Failed to update template:", e);
}
}, [editingId, editingName, loadTemplates]);

const handleCancelEdit = useCallback(() => {
setEditingId(null);
setEditingName("");
}, []);

const handleDeleteClick = useCallback((templateId: string) => {
setDeleteConfirmId(templateId);
setEditingId(null);
}, []);

const handleConfirmDelete = useCallback(
async (templateId: string) => {
try {
await TabTemplateService.DeleteTabTemplate(templateId);
setDeleteConfirmId(null);
loadTemplates();
} catch (e) {
console.error("Failed to delete template:", e);
}
},
[loadTemplates]
);

const handleCancelDelete = useCallback(() => {
setDeleteConfirmId(null);
}, []);

const handleKeyDown = useCallback(
(waveEvent: WaveKeyboardEvent): boolean => {
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
if (editingId) {
handleCancelEdit();
} else if (deleteConfirmId) {
handleCancelDelete();
} else {
handleClose();
}
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Enter") && editingId) {
handleSaveEdit();
return true;
}
return false;
},
[editingId, deleteConfirmId, handleCancelEdit, handleCancelDelete, handleClose, handleSaveEdit]
);

return (
<Modal className="pt-6 pb-4 px-5 min-w-[450px]" onClose={handleClose}>
<div className="font-bold text-primary mx-4 pb-2.5">Manage Tab Templates</div>
<div className="flex flex-col gap-2 mx-4 mb-4 max-h-[400px] overflow-y-auto">
{loading && <div className="text-secondary text-sm">Loading templates...</div>}
{!loading && templates.length === 0 && (
<div className="text-secondary text-sm py-4 text-center">
No templates saved yet. Right-click a tab and select "Save as Template" to create one.
</div>
)}
{!loading &&
templates.map((template) => (
<div
key={template.oid}
className="flex items-center justify-between p-2 rounded-md bg-panel hover:bg-hoverbg"
>
{editingId === template.oid ? (
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 bg-transparent border border-border rounded px-2 py-1 text-inherit focus:outline-none focus:ring-1 focus:ring-accent"
autoFocus
onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
onBlur={handleSaveEdit}
/>
) : (
<span className="flex-1 text-primary">{template.name}</span>
)}
<div className="flex items-center gap-2 ml-2">
{deleteConfirmId === template.oid ? (
<>
<span className="text-sm text-secondary mr-2">Delete?</span>
<Button
className="ghost text-sm"
onClick={() => handleConfirmDelete(template.oid)}
>
Yes
</Button>
<Button className="ghost text-sm" onClick={handleCancelDelete}>
No
</Button>
</>
) : (
<>
<Button
className="ghost text-sm"
onClick={() => handleStartEdit(template)}
title="Rename"
>
<i className="fa fa-pencil" />
</Button>
<Button
className="ghost text-sm text-red-400 hover:text-red-300"
onClick={() => handleDeleteClick(template.oid)}
title="Delete"
>
<i className="fa fa-trash" />
</Button>
</>
)}
</div>
</div>
))}
</div>
<div className="flex justify-end mx-4">
<Button className="grey ghost" onClick={handleClose}>
Close
</Button>
</div>
</Modal>
);
};

TemplateManagerModal.displayName = "TemplateManagerModal";

export { TemplateManagerModal };
41 changes: 41 additions & 0 deletions frontend/app/store/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,45 @@ export class ObjectServiceType {

export const ObjectService = new ObjectServiceType();

// tabtemplateservice.TabTemplateService (tabtemplate)
export class TabTemplateServiceType {
waveEnv: WaveEnv;

constructor(waveEnv?: WaveEnv) {
this.waveEnv = waveEnv;
}

// @returns tabId (and object updates)
CreateTabFromTemplate(workspaceId: string, templateId: string): Promise<string> {
return callBackendService(this?.waveEnv, "tabtemplate", "CreateTabFromTemplate", Array.from(arguments))
}
DeleteTabTemplate(templateId: string): Promise<void> {
return callBackendService(this?.waveEnv, "tabtemplate", "DeleteTabTemplate", Array.from(arguments))
}

// @returns template
GetTabTemplate(templateId: string): Promise<TabTemplate> {
return callBackendService(this?.waveEnv, "tabtemplate", "GetTabTemplate", Array.from(arguments))
}

// @returns templates
ListTabTemplates(): Promise<TabTemplate[]> {
return callBackendService(this?.waveEnv, "tabtemplate", "ListTabTemplates", Array.from(arguments))
}

// @returns templateId
SaveTabAsTemplate(tabId: string, name: string): Promise<string> {
return callBackendService(this?.waveEnv, "tabtemplate", "SaveTabAsTemplate", Array.from(arguments))
}

// @returns object updates
UpdateTabTemplate(templateId: string, name: string): Promise<void> {
return callBackendService(this?.waveEnv, "tabtemplate", "UpdateTabTemplate", Array.from(arguments))
}
}

export const TabTemplateService = new TabTemplateServiceType();

// userinputservice.UserInputService (userinput)
export class UserInputServiceType {
waveEnv: WaveEnv;
Expand Down Expand Up @@ -221,6 +260,7 @@ export const AllServiceTypes = {
"block": BlockServiceType,
"client": ClientServiceType,
"object": ObjectServiceType,
"tabtemplate": TabTemplateServiceType,
"userinput": UserInputServiceType,
"window": WindowServiceType,
"workspace": WorkspaceServiceType,
Expand All @@ -230,6 +270,7 @@ export const AllServiceImpls = {
"block": BlockService,
"client": ClientService,
"object": ObjectService,
"tabtemplate": TabTemplateService,
"userinput": UserInputService,
"window": WindowService,
"workspace": WorkspaceService,
Expand Down
Loading