Implement tabs for desktop

This commit is contained in:
Hakan Shehu
2025-10-03 10:20:10 +02:00
parent 4a63e23bc4
commit 78c515dacd
34 changed files with 631 additions and 313 deletions

View File

@@ -0,0 +1,17 @@
import { Migration } from 'kysely';
export const createTabsTable: Migration = {
up: async (db) => {
await db.schema
.createTable('tabs')
.addColumn('id', 'text', (col) => col.primaryKey().notNull())
.addColumn('location', 'text', (col) => col.notNull())
.addColumn('index', 'text', (col) => col.notNull())
.addColumn('created_at', 'text', (col) => col.notNull())
.addColumn('updated_at', 'text')
.execute();
},
down: async (db) => {
await db.schema.dropTable('tabs').execute();
},
};

View File

@@ -8,6 +8,7 @@ import { createJobsTable } from './00005-create-jobs-table';
import { createJobSchedulesTable } from './00006-create-job-schedules-table';
import { dropDeletedTokensTable } from './00007-drop-deleted-tokens-table';
import { createTempFilesTable } from './00008-create-temp-files-table';
import { createTabsTable } from './00009-create-tabs-table';
export const appDatabaseMigrations: Record<string, Migration> = {
'00001-create-servers-table': createServersTable,
@@ -18,4 +19,5 @@ export const appDatabaseMigrations: Record<string, Migration> = {
'00006-create-job-schedules-table': createJobSchedulesTable,
'00007-drop-deleted-tokens-table': dropDeletedTokensTable,
'00008-create-temp-files-table': createTempFilesTable,
'00009-create-tabs-table': createTabsTable,
};

View File

@@ -97,6 +97,18 @@ export type SelectTempFile = Selectable<TempFileTable>;
export type InsertTempFile = Insertable<TempFileTable>;
export type UpdateTempFile = Updateable<TempFileTable>;
interface TabTable {
id: ColumnType<string, string, never>;
location: ColumnType<string, string, string>;
index: ColumnType<string, string, string>;
created_at: ColumnType<string, string, string>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectTab = Selectable<TabTable>;
export type InsertTab = Insertable<TabTable>;
export type UpdateTab = Updateable<TabTable>;
export interface AppDatabaseSchema {
servers: ServerTable;
accounts: AccountTable;
@@ -104,4 +116,5 @@ export interface AppDatabaseSchema {
jobs: JobTableSchema;
job_schedules: JobScheduleTableSchema;
temp_files: TempFileTable;
tabs: TabTable;
}

View File

@@ -0,0 +1,55 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabCreateMutationInput,
TabCreateMutationOutput,
} from '@colanode/client/mutations/apps/tab-create';
import { AppService } from '@colanode/client/services/app-service';
export class TabCreateMutationHandler
implements MutationHandler<TabCreateMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabCreateMutationInput
): Promise<TabCreateMutationOutput> {
const createdTab = await this.app.database
.insertInto('tabs')
.returningAll()
.values({
id: input.id,
location: input.location,
index: input.index,
created_at: new Date().toISOString(),
})
.onConflict((cb) =>
cb.columns(['id']).doUpdateSet({
location: input.location,
index: input.index,
updated_at: new Date().toISOString(),
})
)
.executeTakeFirst();
if (!createdTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.created',
tab: mapTab(createdTab),
});
return {
success: true,
};
}
}

View File

@@ -0,0 +1,43 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabDeleteMutationInput,
TabDeleteMutationOutput,
} from '@colanode/client/mutations/apps/tab-delete';
import { AppService } from '@colanode/client/services/app-service';
export class TabDeleteMutationHandler
implements MutationHandler<TabDeleteMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabDeleteMutationInput
): Promise<TabDeleteMutationOutput> {
const deletedTab = await this.app.database
.deleteFrom('tabs')
.returningAll()
.where('id', '=', input.id)
.executeTakeFirst();
if (!deletedTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.deleted',
tab: mapTab(deletedTab),
});
return {
success: true,
};
}
}

View File

@@ -0,0 +1,48 @@
import { mapTab } from '@colanode/client/lib';
import { eventBus } from '@colanode/client/lib/event-bus';
import { MutationHandler } from '@colanode/client/lib/types';
import {
TabUpdateMutationInput,
TabUpdateMutationOutput,
} from '@colanode/client/mutations/apps/tab-update';
import { AppService } from '@colanode/client/services/app-service';
export class TabUpdateMutationHandler
implements MutationHandler<TabUpdateMutationInput>
{
private readonly app: AppService;
constructor(appService: AppService) {
this.app = appService;
}
async handleMutation(
input: TabUpdateMutationInput
): Promise<TabUpdateMutationOutput> {
const updatedTab = await this.app.database
.updateTable('tabs')
.returningAll()
.set({
location: input.location,
index: input.index,
updated_at: new Date().toISOString(),
})
.where('id', '=', input.id)
.executeTakeFirst();
if (!updatedTab) {
return {
success: false,
};
}
eventBus.publish({
type: 'tab.updated',
tab: mapTab(updatedTab),
});
return {
success: true,
};
}
}

View File

@@ -14,6 +14,9 @@ import { EmailVerifyMutationHandler } from './accounts/email-verify';
import { GoogleLoginMutationHandler } from './accounts/google-login';
import { AppMetadataDeleteMutationHandler } from './apps/app-metadata-delete';
import { AppMetadataUpdateMutationHandler } from './apps/app-metadata-update';
import { TabCreateMutationHandler } from './apps/tab-create';
import { TabDeleteMutationHandler } from './apps/tab-delete';
import { TabUpdateMutationHandler } from './apps/tab-update';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
import { ChannelCreateMutationHandler } from './channels/channel-create';
import { ChannelDeleteMutationHandler } from './channels/channel-delete';
@@ -160,5 +163,8 @@ export const buildMutationHandlerMap = (
'workspace.delete': new WorkspaceDeleteMutationHandler(app),
'user.storage.update': new UserStorageUpdateMutationHandler(app),
'temp.file.create': new TempFileCreateMutationHandler(app),
'tab.create': new TabCreateMutationHandler(app),
'tab.update': new TabUpdateMutationHandler(app),
'tab.delete': new TabDeleteMutationHandler(app),
};
};

View File

@@ -1,4 +1,8 @@
import { mapAccountMetadata, mapWorkspaceMetadata } from '@colanode/client/lib';
import {
mapAccountMetadata,
mapTab,
mapWorkspaceMetadata,
} from '@colanode/client/lib';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import {
AppAccountMetadata,
@@ -12,7 +16,7 @@ import {
import { AccountService } from '@colanode/client/services/accounts/account-service';
import { AppService } from '@colanode/client/services/app-service';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
import { Server } from '@colanode/client/types';
import { Server, Tab } from '@colanode/client/types';
import { Event } from '@colanode/client/types/events';
import { build } from '@colanode/core';
@@ -154,11 +158,13 @@ export class AppStateQueryHandler implements QueryHandler<AppStateQueryInput> {
const metadata = await this.buildAppMetadataState();
const servers = this.buildServersState();
const accounts = await this.buildAccountsState();
const tabs = await this.buildTabsState();
return {
metadata,
servers,
accounts,
tabs,
};
}
@@ -189,7 +195,7 @@ export class AppStateQueryHandler implements QueryHandler<AppStateQueryInput> {
(metadata) => metadata.key === 'window.size'
)?.value;
const tabs = appMetadata.find((metadata) => metadata.key === 'tabs')?.value;
const tab = appMetadata.find((metadata) => metadata.key === 'tab')?.value;
return {
account,
@@ -200,7 +206,7 @@ export class AppStateQueryHandler implements QueryHandler<AppStateQueryInput> {
platform: platform ?? '',
version: version ?? build.version,
windowSize,
tabs: tabs ?? [],
tab,
};
}
@@ -289,18 +295,28 @@ export class AppStateQueryHandler implements QueryHandler<AppStateQueryInput> {
(metadata) => metadata.key === 'sidebar.width'
)?.value;
const sidebarMenu = metadata.find(
(metadata) => metadata.key === 'sidebar.menu'
)?.value;
const location = metadata.find(
(metadata) => metadata.key === 'location'
)?.value;
return {
sidebarWidth,
sidebarMenu,
location,
};
}
private async buildTabsState(): Promise<Record<string, Tab>> {
const tabs = await this.app.database
.selectFrom('tabs')
.selectAll()
.execute();
const output: Record<string, Tab> = {};
for (const tab of tabs) {
output[tab.id] = mapTab(tab);
}
return output;
}
}

View File

@@ -0,0 +1,72 @@
import { SelectTab } from '@colanode/client/databases/app/schema';
import { mapTab } from '@colanode/client/lib/mappers';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import { TabsListQueryInput } from '@colanode/client/queries/apps/tabs-list';
import { AppService } from '@colanode/client/services/app-service';
import { Tab } from '@colanode/client/types/apps';
import { Event } from '@colanode/client/types/events';
export class TabsListQueryHandler implements QueryHandler<TabsListQueryInput> {
private readonly app: AppService;
constructor(app: AppService) {
this.app = app;
}
public async handleQuery(_: TabsListQueryInput): Promise<Tab[]> {
const rows = await this.getAppTabs();
if (!rows) {
return [];
}
return rows.map(mapTab);
}
public async checkForChanges(
event: Event,
_: TabsListQueryInput,
output: Tab[]
): Promise<ChangeCheckResult<TabsListQueryInput>> {
if (event.type === 'tab.created') {
const newOutput = [...output, event.tab];
return {
hasChanges: true,
result: newOutput,
};
}
if (event.type === 'tab.updated') {
const newOutput = [
...output.filter((tab) => tab.id !== event.tab.id),
event.tab,
];
return {
hasChanges: true,
result: newOutput,
};
}
if (event.type === 'tab.deleted') {
const newOutput = output.filter((tab) => tab.id !== event.tab.id);
return {
hasChanges: true,
result: newOutput,
};
}
return {
hasChanges: false,
};
}
private async getAppTabs(): Promise<SelectTab[] | undefined> {
const rows = await this.app.database
.selectFrom('tabs')
.selectAll()
.execute();
return rows;
}
}

View File

@@ -7,6 +7,7 @@ import { AccountMetadataListQueryHandler } from './accounts/account-metadata-lis
import { AccountListQueryHandler } from './accounts/accounts-list';
import { AppMetadataListQueryHandler } from './apps/app-metadata-list';
import { AppStateQueryHandler } from './apps/app-state';
import { TabsListQueryHandler } from './apps/tabs-list';
import { AvatarGetQueryHandler } from './avatars/avatar-get';
import { ChatListQueryHandler } from './chats/chat-list';
import { DatabaseListQueryHandler } from './databases/database-list';
@@ -105,5 +106,6 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
'temp.file.get': new TempFileGetQueryHandler(app),
'icon.svg.get': new IconSvgGetQueryHandler(app),
'emoji.svg.get': new EmojiSvgGetQueryHandler(app),
'tabs.list': new TabsListQueryHandler(app),
};
};

View File

@@ -6,6 +6,7 @@ import {
import {
SelectAccount,
SelectAppMetadata,
SelectTab,
SelectTempFile,
} from '@colanode/client/databases/app';
import { SelectEmoji } from '@colanode/client/databases/emojis';
@@ -30,7 +31,7 @@ import {
AccountMetadata,
AccountMetadataKey,
} from '@colanode/client/types/accounts';
import { AppMetadata, AppMetadataKey } from '@colanode/client/types/apps';
import { AppMetadata, AppMetadataKey, Tab } from '@colanode/client/types/apps';
import { Avatar } from '@colanode/client/types/avatars';
import {
Document,
@@ -276,6 +277,16 @@ export const mapAppMetadata = (row: SelectAppMetadata): AppMetadata => {
};
};
export const mapTab = (row: SelectTab): Tab => {
return {
id: row.id,
location: row.location,
index: row.index,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
};
export const mapAccountMetadata = (
row: SelectAccountMetadata
): AccountMetadata => {

View File

@@ -0,0 +1,19 @@
export type TabCreateMutationInput = {
type: 'tab.create';
id: string;
location: string;
index: string;
};
export type TabCreateMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'tab.create': {
input: TabCreateMutationInput;
output: TabCreateMutationOutput;
};
}
}

View File

@@ -0,0 +1,17 @@
export type TabDeleteMutationInput = {
type: 'tab.delete';
id: string;
};
export type TabDeleteMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'tab.delete': {
input: TabDeleteMutationInput;
output: TabDeleteMutationOutput;
};
}
}

View File

@@ -0,0 +1,19 @@
export type TabUpdateMutationInput = {
type: 'tab.update';
id: string;
location?: string;
index?: string;
};
export type TabUpdateMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'tab.update': {
input: TabUpdateMutationInput;
output: TabUpdateMutationOutput;
};
}
}

View File

@@ -69,6 +69,9 @@ export * from './users/user-role-update';
export * from './users/user-storage-update';
export * from './users/users-create';
export * from './files/temp-file-create';
export * from './apps/tab-create';
export * from './apps/tab-update';
export * from './apps/tab-delete';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MutationMap {}

View File

@@ -1,8 +1,7 @@
import {
Account,
AppTab,
Server,
SidebarMenuType,
Tab,
ThemeColor,
ThemeMode,
WindowSize,
@@ -24,7 +23,6 @@ export type AppAccountState = Account & {
};
export type AppWorkspaceMetadata = {
sidebarMenu?: SidebarMenuType;
sidebarWidth?: number;
location?: string;
};
@@ -42,13 +40,14 @@ export type AppMetadataState = {
mode?: ThemeMode;
color?: ThemeColor;
};
tabs: AppTab[];
tab?: string;
};
export type AppStateOutput = {
metadata: AppMetadataState;
servers: Record<string, Server>;
accounts: Record<string, AppAccountState>;
tabs: Record<string, Tab>;
};
declare module '@colanode/client/queries' {

View File

@@ -0,0 +1,14 @@
import { Tab } from '@colanode/client/types/apps';
export type TabsListQueryInput = {
type: 'tabs.list';
};
declare module '@colanode/client/queries' {
interface QueryMap {
'tabs.list': {
input: TabsListQueryInput;
output: Tab[];
};
}
}

View File

@@ -1,3 +1,5 @@
import { sha256 } from 'js-sha256';
export * from './accounts/account-get';
export * from './accounts/account-list';
export * from './accounts/account-metadata-list';
@@ -46,6 +48,7 @@ export * from './files/upload-list-pending';
export * from './icons/icon-svg-get';
export * from './emojis/emoji-svg-get';
export * from './apps/app-state';
export * from './apps/tabs-list';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface QueryMap {}
@@ -67,3 +70,9 @@ export enum QueryErrorCode {
WorkspaceNotFound = 'workspace_not_found',
ApiError = 'api_error',
}
export const buildQueryKey = <T extends QueryInput>(input: T): string => {
const inputJson = JSON.stringify(input);
const hash = sha256(inputJson);
return hash;
};

View File

@@ -22,7 +22,6 @@ export class AccountSocket {
}
public async init(): Promise<void> {
return;
if (!this.account.server.isAvailable) {
return;
}

View File

@@ -22,7 +22,14 @@ import { PathService } from '@colanode/client/services/path-service';
import { ServerService } from '@colanode/client/services/server-service';
import { Account } from '@colanode/client/types/accounts';
import { ServerAttributes } from '@colanode/client/types/servers';
import { ApiHeader, build, createDebugger } from '@colanode/core';
import {
ApiHeader,
build,
createDebugger,
generateFractionalIndex,
generateId,
IdType,
} from '@colanode/core';
const debug = createDebugger('desktop:service:app');
@@ -126,6 +133,22 @@ export class AppService {
await this.fs.makeDirectory(this.path.temp);
await this.jobs.init();
// make sure there is at least one tab in desktop app
if (this.meta.type === 'desktop') {
const tabs = await this.database.selectFrom('tabs').selectAll().execute();
if (tabs.length === 0) {
await this.database
.insertInto('tabs')
.values({
id: generateId(IdType.Tab),
location: '/',
index: generateFractionalIndex(),
created_at: new Date().toISOString(),
})
.execute();
}
}
const scheduleId = 'temp.files.clean';
await this.jobs.upsertJobSchedule(
scheduleId,

View File

@@ -67,10 +67,10 @@ export class JobService {
.set({ status: JobStatus.Waiting })
.execute();
// this.jobLoop().catch((err) => console.error('Job loop error:', err));
// this.scheduleLoop().catch((err) =>
// console.error('Schedule loop error:', err)
// );
this.jobLoop().catch((err) => console.error('Job loop error:', err));
this.scheduleLoop().catch((err) =>
console.error('Schedule loop error:', err)
);
}
public async addJob(

View File

@@ -50,15 +50,9 @@ export type AppThemeColorMetadata = {
updatedAt: string | null;
};
export type AppTab = {
id: string;
location: string;
active: boolean;
};
export type AppTabsMetadata = {
key: 'tabs';
value: AppTab[];
export type AppTabMetadata = {
key: 'tab';
value: string;
createdAt: string;
updatedAt: string | null;
};
@@ -70,7 +64,7 @@ export type AppMetadata =
| AppAccountMetadata
| AppThemeModeMetadata
| AppThemeColorMetadata
| AppTabsMetadata;
| AppTabMetadata;
export type AppMetadataKey = AppMetadata['key'];
@@ -81,5 +75,13 @@ export type AppMetadataMap = {
account: AppAccountMetadata;
'theme.mode': AppThemeModeMetadata;
'theme.color': AppThemeColorMetadata;
tabs: AppTabsMetadata;
tab: AppTabMetadata;
};
export type Tab = {
id: string;
location: string;
index: string;
createdAt: string;
updatedAt: string | null;
};

View File

@@ -1,5 +1,5 @@
import { Account, AccountMetadata } from '@colanode/client/types/accounts';
import { AppMetadata } from '@colanode/client/types/apps';
import { AppMetadata, Tab } from '@colanode/client/types/apps';
import { Avatar } from '@colanode/client/types/avatars';
import {
Document,
@@ -359,6 +359,21 @@ export type TempFileDeletedEvent = {
tempFile: TempFile;
};
export type TabCreatedEvent = {
type: 'tab.created';
tab: Tab;
};
export type TabUpdatedEvent = {
type: 'tab.updated';
tab: Tab;
};
export type TabDeletedEvent = {
type: 'tab.deleted';
tab: Tab;
};
export type Event =
| UserCreatedEvent
| UserUpdatedEvent
@@ -412,4 +427,7 @@ export type Event =
| AvatarCreatedEvent
| AvatarDeletedEvent
| TempFileCreatedEvent
| TempFileDeletedEvent;
| TempFileDeletedEvent
| TabCreatedEvent
| TabUpdatedEvent
| TabDeletedEvent;

View File

@@ -12,15 +12,6 @@ export type Workspace = {
storageLimit: string;
};
export type SidebarMenuType = 'chats' | 'spaces' | 'settings';
export type WorkspaceSidebarMenuMetadata = {
key: 'sidebar.menu';
value: SidebarMenuType;
createdAt: string;
updatedAt: string | null;
};
export type WorkspaceSidebarWidthMetadata = {
key: 'sidebar.width';
value: number;
@@ -36,14 +27,14 @@ export type WorkspaceLocationMetadata = {
};
export type WorkspaceMetadata =
| WorkspaceSidebarMenuMetadata
| WorkspaceSidebarWidthMetadata
| WorkspaceLocationMetadata;
export type WorkspaceMetadataKey = WorkspaceMetadata['key'];
export type WorkspaceMetadataMap = {
'sidebar.menu': WorkspaceSidebarMenuMetadata;
'sidebar.width': WorkspaceSidebarWidthMetadata;
location: WorkspaceLocationMetadata;
};
export type SidebarMenuType = 'chats' | 'spaces' | 'settings';

View File

@@ -1,3 +1,11 @@
export const LayoutTab = () => {
interface LayoutTabProps {
location: string;
}
const LayoutTabContent = ({ location }: LayoutTabProps) => {
return <div>LayoutTabContent</div>;
};
export const LayoutTab = ({ location }: LayoutTabProps) => {
return <div>LayoutTab</div>;
};

View File

@@ -1,236 +1,158 @@
import { Plus, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { AppTab } from '@colanode/client/types';
import { generateId, IdType } from '@colanode/core';
import { Tab } from '@colanode/client/types';
import {
compareString,
generateFractionalIndex,
generateId,
IdType,
} from '@colanode/core';
import { LayoutTabContent } from '@colanode/ui/components/layouts/layout-tab-content';
import { cn } from '@colanode/ui/lib/utils';
import { useAppStore } from '@colanode/ui/stores/app';
export const LayoutTabs = () => {
const tabs = useAppStore((state) => state.metadata.tabs);
const activeTab = tabs.find((tab) => tab.active);
const [draggedTab, setDraggedTab] = useState<string | null>(null);
const [dragOverTab, setDragOverTab] = useState<string | null>(null);
const allTabs = useAppStore((state) => state.tabs);
const activeTabId = useAppStore((state) => state.metadata.tab);
useEffect(() => {
const currentTabs = useAppStore.getState().metadata.tabs;
if (currentTabs.length === 0) {
const newTab: AppTab = {
id: generateId(IdType.Tab),
location: '/',
active: true,
};
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: [newTab],
});
}
}, []);
const tabs = Object.values(allTabs).sort((a, b) =>
compareString(a.index, b.index)
);
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]!;
const deleteTab = useCallback((tabId: string) => {
const currentTabs = useAppStore.getState().metadata.tabs;
const tabToDelete = currentTabs.find((tab) => tab.id === tabId);
const newTabs = currentTabs.filter((tab) => tab.id !== tabId);
if (newTabs.length === 0) {
const currentTabs = useAppStore.getState().tabs;
if (!currentTabs[tabId]) {
return;
}
// If the deleted tab was active, activate another tab
if (tabToDelete?.active) {
const deletedTabIndex = currentTabs.findIndex((tab) => tab.id === tabId);
let tabToActivate: AppTab;
// Try to activate the next tab, or the previous one if it was the last tab
if (deletedTabIndex < newTabs.length) {
// Activate the tab that will be at the same index after deletion
tabToActivate = newTabs[deletedTabIndex]!;
} else {
// Activate the last tab if we deleted the last tab
tabToActivate = newTabs[newTabs.length - 1]!;
}
// Update the tabs with the new active tab
const updatedTabs = newTabs.map((tab) => ({
...tab,
active: tab.id === tabToActivate.id,
}));
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: updatedTabs,
});
} else {
// If the deleted tab wasn't active, just remove it
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: newTabs,
});
if (Object.keys(currentTabs).length === 1) {
return;
}
}, []);
const switchTab = useCallback((tabId: string) => {
const currentTabs = useAppStore.getState().metadata.tabs;
const updatedTabs = currentTabs.map((tab) => ({
...tab,
active: tab.id === tabId,
}));
useAppStore.getState().deleteTab(tabId);
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: updatedTabs,
window.colanode.executeMutation({
type: 'tab.delete',
id: tabId,
});
}, []);
const updateTabLocation = useCallback((location: string) => {
const currentTabs = useAppStore.getState().metadata.tabs;
const updatedTabs = currentTabs.map((tab) =>
tab.active ? { ...tab, location } : tab
);
const switchTab = useCallback((tabId: string) => {
const currentTabs = useAppStore.getState().tabs;
if (!currentTabs[tabId]) {
return;
}
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: updatedTabs,
key: 'tab',
value: tabId,
});
window.colanode.executeMutation({
type: 'app.metadata.update',
key: 'tab',
value: tabId,
});
}, []);
const addTab = useCallback(() => {
const currentTabs = useAppStore.getState().metadata.tabs;
const newTab: AppTab = {
const lastIndex = tabs[tabs.length - 1]?.index;
const tab: Tab = {
id: generateId(IdType.Tab),
location: '/',
active: true,
index: generateFractionalIndex(lastIndex, null),
createdAt: new Date().toISOString(),
updatedAt: null,
};
// Set all existing tabs to inactive
const updatedTabs = currentTabs.map((tab) => ({ ...tab, active: false }));
useAppStore.getState().upsertTab(tab);
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: [...updatedTabs, newTab],
window.colanode.executeMutation({
type: 'tab.create',
id: tab.id,
location: tab.location,
index: tab.index,
});
}, []);
const reorderTabs = useCallback((draggedId: string, targetId: string) => {
const currentTabs = useAppStore.getState().metadata.tabs;
const draggedIndex = currentTabs.findIndex((tab) => tab.id === draggedId);
const targetIndex = currentTabs.findIndex((tab) => tab.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return;
const newTabs = [...currentTabs];
const [draggedTab] = newTabs.splice(draggedIndex, 1);
if (draggedTab) {
newTabs.splice(targetIndex, 0, draggedTab);
const updateTabLocation = useCallback((tabId: string, location: string) => {
const allTabs = useAppStore.getState().tabs;
const tab = allTabs[tabId];
if (!tab) {
return;
}
useAppStore.getState().updateAppMetadata({
key: 'tabs',
value: newTabs,
useAppStore.getState().upsertTab({
...tab,
location,
});
}, []);
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
setDraggedTab(tabId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', tabId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, tabId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverTab(tabId);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverTab(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
const draggedTabId = e.dataTransfer.getData('text/plain');
if (draggedTabId && draggedTabId !== targetTabId) {
reorderTabs(draggedTabId, targetTabId);
}
setDraggedTab(null);
setDragOverTab(null);
},
[reorderTabs]
);
const handleDragEnd = useCallback(() => {
setDraggedTab(null);
setDragOverTab(null);
window.colanode.executeMutation({
type: 'tab.update',
id: tabId,
location,
});
}, []);
return (
<div className="flex flex-col h-full">
{/* Tab bar with browser-like styling */}
<div className="relative flex bg-sidebar border-b border-border h-10 overflow-hidden">
{tabs.map((tab, index) => (
<div
key={tab.id}
draggable
className={cn(
'relative group/tab app-no-drag-region flex items-center gap-2 px-4 py-2 cursor-pointer transition-all duration-200 min-w-[120px] max-w-[240px] flex-1',
// Active tab styling with proper z-index for overlapping
tab.active
? 'bg-background text-foreground z-20 shadow-[0_-2px_8px_rgba(0,0,0,0.1),0_2px_4px_rgba(0,0,0,0.05)] border-t border-l border-r border-border'
: 'bg-sidebar-accent/60 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground z-10 hover:z-15 shadow-[0_1px_3px_rgba(0,0,0,0.1)]',
// Add overlap effect - each tab overlaps the previous one
index > 0 && '-ml-3',
// Drag states
draggedTab === tab.id && 'opacity-50 scale-95',
dragOverTab === tab.id && 'bg-primary/20',
// Ensure proper stacking order
`relative`
)}
style={{
clipPath: tab.active
? 'polygon(12px 0%, calc(100% - 12px) 0%, 100% 100%, 0% 100%)'
: 'polygon(12px 0%, calc(100% - 12px) 0%, calc(100% - 6px) 100%, 6px 100%)',
}}
onClick={() => switchTab(tab.id)}
onDragStart={(e) => handleDragStart(e, tab.id)}
onDragOver={(e) => handleDragOver(e, tab.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, tab.id)}
onDragEnd={handleDragEnd}
>
{/* Tab content */}
<div className="flex items-center gap-2 flex-1 min-w-0 z-10">
<div className="truncate text-sm font-medium">
Tab {index + 1}
</div>
<button
className={cn(
'opacity-0 group-hover/tab:opacity-100 transition-all duration-200 flex-shrink-0 rounded-full p-1 hover:bg-destructive/20 hover:text-destructive',
tab.active && 'opacity-70 hover:opacity-100',
'ml-auto' // Push to the right edge
)}
onClick={(e) => {
e.stopPropagation();
deleteTab(tab.id);
}}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
{/* Browser-like tab separator */}
{!tab.active &&
index < tabs.length - 1 &&
!tabs[index + 1]?.active && (
<div className="absolute right-0 top-2 bottom-2 w-px bg-border/50" />
{tabs.map((tab, index) => {
const isActive = tab.id === activeTab.id;
return (
<div
key={tab.id}
className={cn(
'relative group/tab app-no-drag-region flex items-center gap-2 px-4 py-2 cursor-pointer transition-all duration-200 min-w-[120px] max-w-[240px] flex-1',
// Active tab styling with proper z-index for overlapping
isActive
? 'bg-background text-foreground z-20 shadow-[0_-2px_8px_rgba(0,0,0,0.1),0_2px_4px_rgba(0,0,0,0.05)] border-t border-l border-r border-border'
: 'bg-sidebar-accent/60 text-muted-foreground hover:bg-sidebar-accent hover:text-foreground z-10 hover:z-15 shadow-[0_1px_3px_rgba(0,0,0,0.1)]',
// Add overlap effect - each tab overlaps the previous one
index > 0 && '-ml-3',
// Ensure proper stacking order
`relative`
)}
</div>
))}
style={{
clipPath: isActive
? 'polygon(12px 0%, calc(100% - 12px) 0%, 100% 100%, 0% 100%)'
: 'polygon(12px 0%, calc(100% - 12px) 0%, calc(100% - 6px) 100%, 6px 100%)',
}}
onClick={() => switchTab(tab.id)}
>
{/* Tab content */}
<div className="flex items-center gap-2 flex-1 min-w-0 z-10">
<div className="truncate text-sm font-medium">
Tab {index + 1}
</div>
<button
className={cn(
'opacity-0 group-hover/tab:opacity-100 transition-all duration-200 flex-shrink-0 rounded-full p-1 hover:bg-destructive/20 hover:text-destructive',
isActive && 'opacity-70 hover:opacity-100',
'ml-auto' // Push to the right edge
)}
onClick={(e) => {
e.stopPropagation();
deleteTab(tab.id);
}}
title="Close tab"
>
<X className="size-3" />
</button>
</div>
{/* Browser-like tab separator */}
{!isActive &&
index < tabs.length - 1 &&
tabs[index + 1]?.id !== activeTab.id && (
<div className="absolute right-0 top-2 bottom-2 w-px bg-border/50" />
)}
</div>
);
})}
{/* Add tab button */}
<button
@@ -245,14 +167,27 @@ export const LayoutTabs = () => {
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-background/5 to-border/10" />
</div>
<div className="flex-1 overflow-hidden">
{activeTab && (
<LayoutTabContent
key={activeTab.id}
location={activeTab.location}
onChange={updateTabLocation}
/>
)}
<div className="flex-1 overflow-hidden relative">
{tabs.map((tab) => {
const isActive = tab.id === activeTab.id;
return (
<div
key={tab.id}
className={cn(
'absolute inset-0 transition-opacity duration-200',
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0'
)}
style={{
pointerEvents: isActive ? 'auto' : 'none',
}}
>
<LayoutTabContent
location={tab.location}
onChange={(location) => updateTabLocation(tab.id, location)}
/>
</div>
);
})}
</div>
</div>
);

View File

@@ -1,12 +1,12 @@
import { Outlet } from '@tanstack/react-router';
import { useRef, useState } from 'react';
import { SidebarMobile } from '@colanode/ui/components/workspaces/sidebars/sidebar-mobile';
import {
ScrollArea,
ScrollBar,
ScrollViewport,
} from '@colanode/ui/components/ui/scroll-area';
import { SidebarMobile } from '@colanode/ui/components/workspaces/sidebars/sidebar-mobile';
import { useApp } from '@colanode/ui/contexts/app';
import { ContainerContext } from '@colanode/ui/contexts/container';
import { useIsMobile } from '@colanode/ui/hooks/use-is-mobile';

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useState } from 'react';
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarChats } from '@colanode/ui/components/workspaces/sidebars/sidebar-chats';
@@ -6,56 +6,11 @@ import { SidebarMenu } from '@colanode/ui/components/workspaces/sidebars/sidebar
import { SidebarSettings } from '@colanode/ui/components/workspaces/sidebars/sidebar-settings';
import { SidebarSpaces } from '@colanode/ui/components/workspaces/sidebars/sidebar-spaces';
import { useApp } from '@colanode/ui/contexts/app';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { cn } from '@colanode/ui/lib/utils';
import { useAppStore } from '@colanode/ui/stores/app';
const DEFAULT_MENU = 'spaces';
export const Sidebar = () => {
const app = useApp();
const workspace = useWorkspace();
const mutation = useMutation();
const updateWorkspaceMetadata = useAppStore(
(state) => state.updateWorkspaceMetadata
);
const menu =
useAppStore((state) => {
const account = state.accounts[workspace.accountId];
if (!account) {
return undefined;
}
const workspaceState = account.workspaces[workspace.id];
if (!workspaceState) {
return undefined;
}
return workspaceState.metadata.sidebarMenu;
}) ?? DEFAULT_MENU;
const handleMenuChange = useCallback(
(newMenu: SidebarMenuType) => {
mutation.mutate({
input: {
type: 'workspace.metadata.update',
key: 'sidebar.menu',
value: newMenu,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
});
updateWorkspaceMetadata(workspace.accountId, workspace.id, {
key: 'sidebar.menu',
value: newMenu,
});
},
[workspace.accountId, workspace.id, menu]
);
const [menu, setMenu] = useState<SidebarMenuType>('spaces');
return (
<div
@@ -64,7 +19,7 @@ export const Sidebar = () => {
app.type === 'mobile' ? 'bg-background' : 'bg-sidebar'
)}
>
<SidebarMenu value={menu} onChange={handleMenuChange} />
<SidebarMenu value={menu} onChange={setMenu} />
<div className="min-h-0 flex-grow overflow-auto border-l border-sidebar-border">
{menu === 'spaces' && <SidebarSpaces />}
{menu === 'chats' && <SidebarChats />}

View File

@@ -0,0 +1,7 @@
interface WorkspaceTabProps {
workspaceId: string;
}
export const WorkspaceTab = ({ workspaceId }: WorkspaceTabProps) => {
return <div>WorkspaceTab {workspaceId}</div>;
};

View File

@@ -58,6 +58,12 @@ export const useAppStoreSubscriptions = () => {
useAppStore.getState().upsertServer(event.server);
} else if (event.type === 'server.deleted') {
useAppStore.getState().deleteServer(event.server.domain);
} else if (event.type === 'tab.created') {
useAppStore.getState().upsertTab(event.tab);
} else if (event.type === 'tab.updated') {
useAppStore.getState().upsertTab(event.tab);
} else if (event.type === 'tab.deleted') {
useAppStore.getState().deleteTab(event.tab.id);
}
});

View File

@@ -1,15 +1,14 @@
import { useQueries as useTanstackQueries } from '@tanstack/react-query';
import { sha256 } from 'js-sha256';
import { QueryInput } from '@colanode/client/queries';
import { QueryInput, buildQueryKey } from '@colanode/client/queries';
export const useLiveQueries = <T extends QueryInput>(inputs: T[]) => {
const result = useTanstackQueries({
queries: inputs.map((input) => {
const hash = sha256(JSON.stringify(input));
const key = buildQueryKey(input);
return {
queryKey: [hash],
queryFn: () => window.colanode.executeQueryAndSubscribe(hash, input),
queryKey: [key],
queryFn: () => window.colanode.executeQueryAndSubscribe(key, input),
};
}),
});

View File

@@ -2,9 +2,8 @@ import {
useQuery as useTanstackQuery,
UseQueryOptions as TanstackUseQueryOptions,
} from '@tanstack/react-query';
import { sha256 } from 'js-sha256';
import { QueryInput, QueryMap } from '@colanode/client/queries';
import { QueryInput, QueryMap, buildQueryKey } from '@colanode/client/queries';
type UseLiveQueryOptions<T extends QueryInput> = Omit<
TanstackUseQueryOptions<QueryMap[T['type']]['output']>,
@@ -15,12 +14,11 @@ export const useLiveQuery = <T extends QueryInput>(
input: T,
options?: UseLiveQueryOptions<T>
) => {
const inputJson = JSON.stringify(input);
const hash = sha256(inputJson);
const key = buildQueryKey(input);
const result = useTanstackQuery({
queryKey: [hash],
queryFn: () => window.colanode.executeQueryAndSubscribe(hash, input),
queryKey: [key],
queryFn: () => window.colanode.executeQueryAndSubscribe(key, input),
...options,
});

View File

@@ -21,6 +21,7 @@ import { WorkspaceCreateScreen } from '@colanode/ui/components/workspaces/worksp
import { WorkspaceHomeScreen } from '@colanode/ui/components/workspaces/workspace-home-screen';
import { WorkspaceScreen } from '@colanode/ui/components/workspaces/workspace-screen';
import { WorkspaceSettingsScreen } from '@colanode/ui/components/workspaces/workspace-settings-screen';
import { WorkspaceTab } from '@colanode/ui/components/workspaces/workspace-tab';
import { WorkspaceUsersScreen } from '@colanode/ui/components/workspaces/workspace-users-screen';
import { useAppStore } from '@colanode/ui/stores/app';
@@ -140,6 +141,11 @@ export const workspaceRoute = createRoute({
getParentRoute: () => accountRoute,
path: '/$workspaceId',
component: WorkspaceScreen,
context: (ctx) => {
return {
tab: <WorkspaceTab workspaceId={ctx.params.workspaceId} />,
};
},
});
export const workspaceMaskRoute = createRoute({

View File

@@ -8,10 +8,9 @@ import {
AccountMetadataKey,
AppMetadata,
AppMetadataKey,
AppTab,
Server,
ServerState,
SidebarMenuType,
Tab,
ThemeColor,
ThemeMode,
WindowSize,
@@ -47,6 +46,8 @@ interface AppStore extends AppStateOutput {
upsertServer: (server: Server) => void;
deleteServer: (domain: string) => void;
updateServerState: (domain: string, state: ServerState) => void;
upsertTab: (tab: Tab) => void;
deleteTab: (id: string) => void;
}
export const useAppStore = create<AppStore>()(
@@ -60,6 +61,7 @@ export const useAppStore = create<AppStore>()(
},
servers: {},
accounts: {},
tabs: {},
initialize: (appState) => set({ ...appState, initialized: true }),
updateAppMetadata: (metadata) =>
set((state) => {
@@ -75,8 +77,8 @@ export const useAppStore = create<AppStore>()(
state.metadata.account = metadata.value as string;
} else if (metadata.key === 'window.size') {
state.metadata.windowSize = metadata.value as WindowSize;
} else if (metadata.key === 'tabs') {
state.metadata.tabs = metadata.value as AppTab[];
} else if (metadata.key === 'tab') {
state.metadata.tab = metadata.value as string;
}
}),
deleteAppMetadata: (key) =>
@@ -89,8 +91,8 @@ export const useAppStore = create<AppStore>()(
state.metadata.account = undefined;
} else if (key === 'window.size') {
state.metadata.windowSize = undefined;
} else if (key === 'tabs') {
state.metadata.tabs = [];
} else if (key === 'tab') {
state.metadata.tab = undefined;
}
}),
upsertAccount: (account) =>
@@ -186,9 +188,6 @@ export const useAppStore = create<AppStore>()(
if (metadata.key === 'sidebar.width') {
existingWorkspace.metadata.sidebarWidth = metadata.value as number;
} else if (metadata.key === 'sidebar.menu') {
existingWorkspace.metadata.sidebarMenu =
metadata.value as SidebarMenuType;
} else if (metadata.key === 'location') {
existingWorkspace.metadata.location = metadata.value as string;
}
@@ -208,8 +207,6 @@ export const useAppStore = create<AppStore>()(
const existingMetadata = { ...existingWorkspace.metadata };
if (key === 'sidebar.width') {
existingMetadata.sidebarWidth = undefined;
} else if (key === 'sidebar.menu') {
existingMetadata.sidebarMenu = undefined;
} else if (key === 'location') {
existingMetadata.location = undefined;
}
@@ -232,5 +229,14 @@ export const useAppStore = create<AppStore>()(
server.state = serverState;
}),
upsertTab: (tab) =>
set((state) => {
state.tabs[tab.id] = tab;
}),
deleteTab: (id) =>
set((state) => {
const { tabs } = state;
delete tabs[id];
}),
}))
);