mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement tabs for desktop
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
55
packages/client/src/handlers/mutations/apps/tab-create.ts
Normal file
55
packages/client/src/handlers/mutations/apps/tab-create.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
packages/client/src/handlers/mutations/apps/tab-delete.ts
Normal file
43
packages/client/src/handlers/mutations/apps/tab-delete.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
48
packages/client/src/handlers/mutations/apps/tab-update.ts
Normal file
48
packages/client/src/handlers/mutations/apps/tab-update.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
72
packages/client/src/handlers/queries/apps/tabs-list.ts
Normal file
72
packages/client/src/handlers/queries/apps/tabs-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
19
packages/client/src/mutations/apps/tab-create.ts
Normal file
19
packages/client/src/mutations/apps/tab-create.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
17
packages/client/src/mutations/apps/tab-delete.ts
Normal file
17
packages/client/src/mutations/apps/tab-delete.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/client/src/mutations/apps/tab-update.ts
Normal file
19
packages/client/src/mutations/apps/tab-update.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' {
|
||||
|
||||
14
packages/client/src/queries/apps/tabs-list.ts
Normal file
14
packages/client/src/queries/apps/tabs-list.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ export class AccountSocket {
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
return;
|
||||
if (!this.account.server.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
7
packages/ui/src/components/workspaces/workspace-tab.tsx
Normal file
7
packages/ui/src/components/workspaces/workspace-tab.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
interface WorkspaceTabProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const WorkspaceTab = ({ workspaceId }: WorkspaceTabProps) => {
|
||||
return <div>WorkspaceTab {workspaceId}</div>;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
@@ -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];
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user