Implement save file (#108)

* Implement save file
* Delete locally downloaded files that have not been opened in last 7 days
This commit is contained in:
Hakan Shehu
2025-07-03 20:42:21 +02:00
committed by GitHub
parent b1c4069743
commit e8f56449d5
45 changed files with 1165 additions and 409 deletions

View File

@@ -5,6 +5,7 @@ import {
protocol, protocol,
shell, shell,
globalShortcut, globalShortcut,
dialog,
} from 'electron'; } from 'electron';
import started from 'electron-squirrel-startup'; import started from 'electron-squirrel-startup';
@@ -251,3 +252,22 @@ ipcMain.handle(
ipcMain.handle('open-external-url', (_, url: string) => { ipcMain.handle('open-external-url', (_, url: string) => {
shell.openExternal(url); shell.openExternal(url);
}); });
ipcMain.handle('show-item-in-folder', (_, path: string) => {
shell.showItemInFolder(path);
});
ipcMain.handle(
'show-file-save-dialog',
async (_, { name }: { name: string }) => {
const result = await dialog.showSaveDialog({
defaultPath: name,
});
if (result.canceled) {
return undefined;
}
return result.filePath;
}
);

View File

@@ -55,6 +55,14 @@ contextBridge.exposeInMainWorld('colanode', {
openExternalUrl: async (url: string) => { openExternalUrl: async (url: string) => {
return ipcRenderer.invoke('open-external-url', url); return ipcRenderer.invoke('open-external-url', url);
}, },
showItemInFolder: async (path: string) => {
return ipcRenderer.invoke('show-item-in-folder', path);
},
showFileSaveDialog: async ({ name }: { name: string }) => {
return ipcRenderer.invoke('show-file-save-dialog', { name });
},
}); });
contextBridge.exposeInMainWorld('eventBus', { contextBridge.exposeInMainWorld('eventBus', {

View File

@@ -40,6 +40,10 @@ const initializeApp = async () => {
openExternalUrl: async (url) => { openExternalUrl: async (url) => {
window.open(url, '_blank'); window.open(url, '_blank');
}, },
showItemInFolder: async () => {
// No-op on web
},
showFileSaveDialog: async () => undefined,
}; };
window.eventBus = eventBus; window.eventBus = eventBus;

View File

@@ -90,7 +90,7 @@ export class FileDownloadMutationHandler
type: 'file.state.updated', type: 'file.state.updated',
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, null),
}); });
return { return {

View File

@@ -1,53 +0,0 @@
// import path from 'path';
// import fs from 'fs';
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
import { MutationHandler } from '@colanode/client/lib/types';
import {
FileSaveTempMutationInput,
FileSaveTempMutationOutput,
} from '@colanode/client/mutations';
export class FileSaveTempMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<FileSaveTempMutationInput>
{
async handleMutation(
_: FileSaveTempMutationInput
): Promise<FileSaveTempMutationOutput> {
throw new Error('Not implemented');
// const workspace = this.getWorkspace(input.accountId, input.workspaceId);
// const directoryPath = this.app.paths.workspaceTempFiles(
// workspace.accountId,
// workspace.id
// );
// const fileName = this.generateUniqueName(directoryPath, input.name);
// const filePath = path.join(directoryPath, fileName);
// if (!fs.existsSync(directoryPath)) {
// fs.mkdirSync(directoryPath, { recursive: true });
// }
// const buffer = Buffer.from(input.buffer);
// fs.writeFileSync(filePath, buffer);
// return {
// path: filePath,
// };
}
// private generateUniqueName(directoryPath: string, name: string): string {
// let result = name;
// let counter = 1;
// while (fs.existsSync(path.join(directoryPath, result))) {
// const nameWithoutExtension = path.basename(name, path.extname(name));
// const extension = path.extname(name);
// result = `${nameWithoutExtension}_${counter}${extension}`;
// counter++;
// }
// return result;
// }
}

View File

@@ -0,0 +1,48 @@
import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base';
import { mapNode } from '@colanode/client/lib/mappers';
import { MutationHandler } from '@colanode/client/lib/types';
import { MutationError, MutationErrorCode } from '@colanode/client/mutations';
import {
FileSaveMutationInput,
FileSaveMutationOutput,
} from '@colanode/client/mutations/files/file-save';
import { LocalFileNode } from '@colanode/client/types';
import { FileStatus } from '@colanode/core';
export class FileSaveMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<FileSaveMutationInput>
{
async handleMutation(
input: FileSaveMutationInput
): Promise<FileSaveMutationOutput> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const node = await workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', input.fileId)
.executeTakeFirst();
if (!node) {
throw new MutationError(
MutationErrorCode.FileNotFound,
'The file you are trying to save does not exist.'
);
}
const file = mapNode(node) as LocalFileNode;
if (file.attributes.status !== FileStatus.Ready) {
throw new MutationError(
MutationErrorCode.FileNotReady,
'The file you are trying to download is not uploaded by the author yet.'
);
}
workspace.files.saveFile(file, input.path);
return {
success: true,
};
}
}

View File

@@ -36,7 +36,7 @@ import { DocumentUpdateMutationHandler } from './documents/document-update';
import { FileCreateMutationHandler } from './files/file-create'; import { FileCreateMutationHandler } from './files/file-create';
import { FileDeleteMutationHandler } from './files/file-delete'; import { FileDeleteMutationHandler } from './files/file-delete';
import { FileDownloadMutationHandler } from './files/file-download'; import { FileDownloadMutationHandler } from './files/file-download';
import { FileSaveTempMutationHandler } from './files/file-save-temp'; import { FileSaveMutationHandler } from './files/file-save';
import { FolderCreateMutationHandler } from './folders/folder-create'; import { FolderCreateMutationHandler } from './folders/folder-create';
import { FolderDeleteMutationHandler } from './folders/folder-delete'; import { FolderDeleteMutationHandler } from './folders/folder-delete';
import { FolderUpdateMutationHandler } from './folders/folder-update'; import { FolderUpdateMutationHandler } from './folders/folder-update';
@@ -129,7 +129,7 @@ export const buildMutationHandlerMap = (
'folder.create': new FolderCreateMutationHandler(app), 'folder.create': new FolderCreateMutationHandler(app),
'file.create': new FileCreateMutationHandler(app), 'file.create': new FileCreateMutationHandler(app),
'file.download': new FileDownloadMutationHandler(app), 'file.download': new FileDownloadMutationHandler(app),
'file.save.temp': new FileSaveTempMutationHandler(app), 'file.save': new FileSaveMutationHandler(app),
'space.avatar.update': new SpaceAvatarUpdateMutationHandler(app), 'space.avatar.update': new SpaceAvatarUpdateMutationHandler(app),
'space.description.update': new SpaceDescriptionUpdateMutationHandler(app), 'space.description.update': new SpaceDescriptionUpdateMutationHandler(app),
'space.name.update': new SpaceNameUpdateMutationHandler(app), 'space.name.update': new SpaceNameUpdateMutationHandler(app),

View File

@@ -0,0 +1,40 @@
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import {
FileDownloadRequestGetQueryInput,
FileDownloadRequestGetQueryOutput,
} from '@colanode/client/queries/files/file-download-request-get';
import { ApiHeader, build } from '@colanode/core';
export class FileDownloadRequestGetQueryHandler
extends WorkspaceQueryHandlerBase
implements QueryHandler<FileDownloadRequestGetQueryInput>
{
public async handleQuery(
input: FileDownloadRequestGetQueryInput
): Promise<FileDownloadRequestGetQueryOutput | null> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const baseUrl = workspace.account.server.httpBaseUrl;
const url = `${baseUrl}/v1/workspaces/${workspace.id}/files/${input.id}`;
const headers: Record<string, string> = {
Authorization: `Bearer ${workspace.account.token}`,
[ApiHeader.ClientType]: this.app.meta.type,
[ApiHeader.ClientPlatform]: this.app.meta.platform,
[ApiHeader.ClientVersion]: build.version,
};
return {
url,
headers,
};
}
public async checkForChanges(): Promise<
ChangeCheckResult<FileDownloadRequestGetQueryInput>
> {
return {
hasChanges: false,
};
}
}

View File

@@ -0,0 +1,54 @@
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import { FileSaveListQueryInput } from '@colanode/client/queries/files/file-save-list';
import { Event } from '@colanode/client/types/events';
import { FileSaveState } from '@colanode/client/types/files';
export class FileSaveListQueryHandler
extends WorkspaceQueryHandlerBase
implements QueryHandler<FileSaveListQueryInput>
{
public async handleQuery(
input: FileSaveListQueryInput
): Promise<FileSaveState[]> {
return this.getSaves(input);
}
public async checkForChanges(
event: Event,
input: FileSaveListQueryInput,
_: FileSaveState[]
): Promise<ChangeCheckResult<FileSaveListQueryInput>> {
if (
event.type === 'workspace.deleted' &&
event.workspace.accountId === input.accountId &&
event.workspace.id === input.workspaceId
) {
return {
hasChanges: true,
result: [],
};
}
if (
event.type === 'file.save.updated' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId
) {
return {
hasChanges: true,
result: this.getSaves(input),
};
}
return {
hasChanges: false,
};
}
private getSaves(input: FileSaveListQueryInput): FileSaveState[] {
const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const saves = workspace.files.getSaves();
return saves;
}
}

View File

@@ -1,9 +1,10 @@
import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base'; import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base';
import { mapFileState } from '@colanode/client/lib/mappers'; import { mapFileState, mapNode } from '@colanode/client/lib/mappers';
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types'; import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import { FileStateGetQueryInput } from '@colanode/client/queries/files/file-state-get'; import { FileStateGetQueryInput } from '@colanode/client/queries/files/file-state-get';
import { LocalFileNode } from '@colanode/client/types';
import { Event } from '@colanode/client/types/events'; import { Event } from '@colanode/client/types/events';
import { FileState } from '@colanode/client/types/files'; import { DownloadStatus, FileState } from '@colanode/client/types/files';
export class FileStateGetQueryHandler export class FileStateGetQueryHandler
extends WorkspaceQueryHandlerBase extends WorkspaceQueryHandlerBase
@@ -44,6 +45,18 @@ export class FileStateGetQueryHandler
}; };
} }
if (
event.type === 'file.state.deleted' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.fileId === input.id
) {
return {
hasChanges: true,
result: null,
};
}
if ( if (
event.type === 'node.deleted' && event.type === 'node.deleted' &&
event.accountId === input.accountId && event.accountId === input.accountId &&
@@ -79,6 +92,17 @@ export class FileStateGetQueryHandler
): Promise<FileState | null> { ): Promise<FileState | null> {
const workspace = this.getWorkspace(input.accountId, input.workspaceId); const workspace = this.getWorkspace(input.accountId, input.workspaceId);
const node = await workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', input.id)
.executeTakeFirst();
if (!node) {
return null;
}
const file = mapNode(node) as LocalFileNode;
const fileState = await workspace.database const fileState = await workspace.database
.selectFrom('file_states') .selectFrom('file_states')
.selectAll() .selectAll()
@@ -89,6 +113,21 @@ export class FileStateGetQueryHandler
return null; return null;
} }
return mapFileState(fileState); let url: string | null = null;
if (fileState.download_status === DownloadStatus.Completed) {
const filePath = this.app.path.workspaceFile(
input.accountId,
input.workspaceId,
input.id,
file.attributes.extension
);
const exists = await this.app.fs.exists(filePath);
if (exists) {
url = await this.app.fs.url(filePath);
}
}
return mapFileState(fileState, url);
} }
} }

View File

@@ -1,109 +0,0 @@
import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib/types';
import {
FileUrlGetQueryInput,
FileUrlGetQueryOutput,
} from '@colanode/client/queries/files/file-url-get';
import { AppService } from '@colanode/client/services';
import { Event } from '@colanode/client/types/events';
export class FileUrlGetQueryHandler
implements QueryHandler<FileUrlGetQueryInput>
{
private readonly app: AppService;
constructor(app: AppService) {
this.app = app;
}
public async handleQuery(
input: FileUrlGetQueryInput
): Promise<FileUrlGetQueryOutput> {
return await this.buildFileUrl(input);
}
public async checkForChanges(
event: Event,
input: FileUrlGetQueryInput,
_: FileUrlGetQueryOutput
): Promise<ChangeCheckResult<FileUrlGetQueryInput>> {
if (
event.type === 'workspace.deleted' &&
event.workspace.accountId === input.accountId &&
event.workspace.id === input.workspaceId
) {
return {
hasChanges: true,
result: {
url: null,
},
};
}
if (
event.type === 'file.state.updated' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.fileState.id === input.id
) {
const output = await this.handleQuery(input);
return {
hasChanges: true,
result: output,
};
}
if (
event.type === 'node.deleted' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.node.id === input.id
) {
return {
hasChanges: true,
result: {
url: null,
},
};
}
if (
event.type === 'node.created' &&
event.accountId === input.accountId &&
event.workspaceId === input.workspaceId &&
event.node.id === input.id
) {
const newOutput = await this.handleQuery(input);
return {
hasChanges: true,
result: newOutput,
};
}
return {
hasChanges: false,
};
}
private async buildFileUrl(
input: FileUrlGetQueryInput
): Promise<FileUrlGetQueryOutput> {
const filePath = this.app.path.workspaceFile(
input.accountId,
input.workspaceId,
input.id,
input.extension
);
const exists = await this.app.fs.exists(filePath);
if (!exists) {
return {
url: null,
};
}
const url = await this.app.fs.url(filePath);
return {
url,
};
}
}

View File

@@ -18,9 +18,10 @@ import { EmojiGetQueryHandler } from './emojis/emoji-get';
import { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id'; import { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id';
import { EmojiListQueryHandler } from './emojis/emoji-list'; import { EmojiListQueryHandler } from './emojis/emoji-list';
import { EmojiSearchQueryHandler } from './emojis/emoji-search'; import { EmojiSearchQueryHandler } from './emojis/emoji-search';
import { FileDownloadRequestGetQueryHandler } from './files/file-download-request-get';
import { FileListQueryHandler } from './files/file-list'; import { FileListQueryHandler } from './files/file-list';
import { FileSaveListQueryHandler } from './files/file-save-list';
import { FileStateGetQueryHandler } from './files/file-state-get'; import { FileStateGetQueryHandler } from './files/file-state-get';
import { FileUrlGetQueryHandler } from './files/file-url-get';
import { IconCategoryListQueryHandler } from './icons/icon-category-list'; import { IconCategoryListQueryHandler } from './icons/icon-category-list';
import { IconListQueryHandler } from './icons/icon-list'; import { IconListQueryHandler } from './icons/icon-list';
import { IconSearchQueryHandler } from './icons/icon-search'; import { IconSearchQueryHandler } from './icons/icon-search';
@@ -62,7 +63,6 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
'workspace.list': new WorkspaceListQueryHandler(app), 'workspace.list': new WorkspaceListQueryHandler(app),
'user.list': new UserListQueryHandler(app), 'user.list': new UserListQueryHandler(app),
'file.list': new FileListQueryHandler(app), 'file.list': new FileListQueryHandler(app),
'file.url.get': new FileUrlGetQueryHandler(app),
'emoji.list': new EmojiListQueryHandler(app), 'emoji.list': new EmojiListQueryHandler(app),
'emoji.get': new EmojiGetQueryHandler(app), 'emoji.get': new EmojiGetQueryHandler(app),
'emoji.get.by.skin.id': new EmojiGetBySkinIdQueryHandler(app), 'emoji.get.by.skin.id': new EmojiGetBySkinIdQueryHandler(app),
@@ -80,6 +80,8 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => {
'record.search': new RecordSearchQueryHandler(app), 'record.search': new RecordSearchQueryHandler(app),
'user.get': new UserGetQueryHandler(app), 'user.get': new UserGetQueryHandler(app),
'file.state.get': new FileStateGetQueryHandler(app), 'file.state.get': new FileStateGetQueryHandler(app),
'file.download.request.get': new FileDownloadRequestGetQueryHandler(app),
'file.save.list': new FileSaveListQueryHandler(app),
'chat.list': new ChatListQueryHandler(app), 'chat.list': new ChatListQueryHandler(app),
'space.list': new SpaceListQueryHandler(app), 'space.list': new SpaceListQueryHandler(app),
'workspace.metadata.list': new WorkspaceMetadataListQueryHandler(app), 'workspace.metadata.list': new WorkspaceMetadataListQueryHandler(app),

View File

@@ -187,7 +187,10 @@ export const mapNodeInteraction = (
}; };
}; };
export const mapFileState = (row: SelectFileState): FileState => { export const mapFileState = (
row: SelectFileState,
url: string | null
): FileState => {
return { return {
id: row.id, id: row.id,
version: row.version, version: row.version,
@@ -201,6 +204,7 @@ export const mapFileState = (row: SelectFileState): FileState => {
uploadRetries: row.upload_retries, uploadRetries: row.upload_retries,
uploadStartedAt: row.upload_started_at, uploadStartedAt: row.upload_started_at,
uploadCompletedAt: row.upload_completed_at, uploadCompletedAt: row.upload_completed_at,
url,
}; };
}; };

View File

@@ -3,6 +3,7 @@ export type FileDownloadMutationInput = {
accountId: string; accountId: string;
workspaceId: string; workspaceId: string;
fileId: string; fileId: string;
path: string | null;
}; };
export type FileDownloadMutationOutput = { export type FileDownloadMutationOutput = {

View File

@@ -1,20 +0,0 @@
export type FileSaveTempMutationInput = {
type: 'file.save.temp';
accountId: string;
workspaceId: string;
name: string;
buffer: ArrayBuffer;
};
export type FileSaveTempMutationOutput = {
path: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'file.save.temp': {
input: FileSaveTempMutationInput;
output: FileSaveTempMutationOutput;
};
}
}

View File

@@ -0,0 +1,20 @@
export type FileSaveMutationInput = {
type: 'file.save';
accountId: string;
workspaceId: string;
fileId: string;
path: string;
};
export type FileSaveMutationOutput = {
success: boolean;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'file.save': {
input: FileSaveMutationInput;
output: FileSaveMutationOutput;
};
}
}

View File

@@ -32,7 +32,7 @@ export * from './documents/document-update';
export * from './files/file-create'; export * from './files/file-create';
export * from './files/file-delete'; export * from './files/file-delete';
export * from './files/file-download'; export * from './files/file-download';
export * from './files/file-save-temp'; export * from './files/file-save';
export * from './folders/folder-create'; export * from './folders/folder-create';
export * from './folders/folder-delete'; export * from './folders/folder-delete';
export * from './folders/folder-update'; export * from './folders/folder-update';

View File

@@ -0,0 +1,20 @@
export type FileDownloadRequestGetQueryInput = {
type: 'file.download.request.get';
id: string;
accountId: string;
workspaceId: string;
};
export type FileDownloadRequestGetQueryOutput = {
url: string;
headers: Record<string, string>;
};
declare module '@colanode/client/queries' {
interface QueryMap {
'file.download.request.get': {
input: FileDownloadRequestGetQueryInput;
output: FileDownloadRequestGetQueryOutput | null;
};
}
}

View File

@@ -0,0 +1,16 @@
import { FileSaveState } from '@colanode/client/types/files';
export type FileSaveListQueryInput = {
type: 'file.save.list';
accountId: string;
workspaceId: string;
};
declare module '@colanode/client/queries' {
interface QueryMap {
'file.save.list': {
input: FileSaveListQueryInput;
output: FileSaveState[];
};
}
}

View File

@@ -1,20 +0,0 @@
export type FileUrlGetQueryInput = {
type: 'file.url.get';
id: string;
extension: string;
accountId: string;
workspaceId: string;
};
export type FileUrlGetQueryOutput = {
url: string | null;
};
declare module '@colanode/client/queries' {
interface QueryMap {
'file.url.get': {
input: FileUrlGetQueryInput;
output: FileUrlGetQueryOutput;
};
}
}

View File

@@ -15,6 +15,8 @@ export * from './emojis/emoji-list';
export * from './emojis/emoji-search'; export * from './emojis/emoji-search';
export * from './files/file-list'; export * from './files/file-list';
export * from './files/file-state-get'; export * from './files/file-state-get';
export * from './files/file-download-request-get';
export * from './files/file-save-list';
export * from './icons/icon-category-list'; export * from './icons/icon-category-list';
export * from './icons/icon-list'; export * from './icons/icon-list';
export * from './icons/icon-search'; export * from './icons/icon-search';
@@ -36,7 +38,6 @@ export * from './workspaces/workspace-get';
export * from './workspaces/workspace-list'; export * from './workspaces/workspace-list';
export * from './workspaces/workspace-metadata-list'; export * from './workspaces/workspace-metadata-list';
export * from './avatars/avatar-url-get'; export * from './avatars/avatar-url-get';
export * from './files/file-url-get';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface QueryMap {} export interface QueryMap {}

View File

@@ -1,3 +1,4 @@
import { cloneDeep } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { import {
@@ -13,6 +14,8 @@ import { AppService } from '@colanode/client/services/app-service';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
import { import {
DownloadStatus, DownloadStatus,
FileSaveState,
SaveStatus,
TempFile, TempFile,
UploadStatus, UploadStatus,
} from '@colanode/client/types/files'; } from '@colanode/client/types/files';
@@ -36,6 +39,7 @@ export class FileService {
private readonly app: AppService; private readonly app: AppService;
private readonly workspace: WorkspaceService; private readonly workspace: WorkspaceService;
private readonly filesDir: string; private readonly filesDir: string;
private readonly saves: FileSaveState[] = [];
private readonly uploadsEventLoop: EventLoop; private readonly uploadsEventLoop: EventLoop;
private readonly downloadsEventLoop: EventLoop; private readonly downloadsEventLoop: EventLoop;
@@ -64,9 +68,9 @@ export class FileService {
); );
this.cleanupEventLoop = new EventLoop( this.cleanupEventLoop = new EventLoop(
ms('10 minutes'),
ms('5 minutes'), ms('5 minutes'),
this.cleanDeletedFiles.bind(this) ms('1 minute'),
this.cleanupFiles.bind(this)
); );
this.uploadsEventLoop.start(); this.uploadsEventLoop.start();
@@ -162,16 +166,48 @@ export class FileService {
); );
} }
const url = await this.app.fs.url(destinationFilePath);
eventBus.publish({ eventBus.publish({
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(createdFileState), fileState: mapFileState(createdFileState, url),
}); });
this.triggerUploads(); this.triggerUploads();
} }
public saveFile(file: LocalFileNode, path: string): void {
const id = generateId(IdType.Save);
const state: FileSaveState = {
id,
file,
status: SaveStatus.Active,
startedAt: new Date().toISOString(),
completedAt: null,
path,
progress: 0,
};
this.saves.push(state);
this.processSaveAsync(state);
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: state,
});
}
public getSaves(): FileSaveState[] {
const clonedSaves = cloneDeep(this.saves);
return clonedSaves.sort((a, b) => {
// latest first
return -a.id.localeCompare(b.id);
});
}
public async deleteFile(node: SelectNode): Promise<void> { public async deleteFile(node: SelectNode): Promise<void> {
const file = mapNode(node); const file = mapNode(node);
@@ -220,6 +256,37 @@ export class FileService {
} }
private async uploadFile(state: SelectFileState): Promise<void> { private async uploadFile(state: SelectFileState): Promise<void> {
const node = await this.workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', state.id)
.executeTakeFirst();
if (!node) {
return;
}
if (node.server_revision === '0') {
// file is not synced with the server, we need to wait for the sync to complete
return;
}
const file = mapNode(node) as LocalFileNode;
const filePath = this.buildFilePath(file.id, file.attributes.extension);
const exists = await this.app.fs.exists(filePath);
if (!exists) {
debug(`File ${file.id} not found on disk`);
await this.workspace.database
.deleteFrom('file_states')
.returningAll()
.where('id', '=', state.id)
.executeTakeFirst();
return;
}
const url = await this.app.fs.url(filePath);
if (state.upload_retries && state.upload_retries >= UPLOAD_RETRIES_LIMIT) { if (state.upload_retries && state.upload_retries >= UPLOAD_RETRIES_LIMIT) {
debug(`File ${state.id} upload retries limit reached, marking as failed`); debug(`File ${state.id} upload retries limit reached, marking as failed`);
@@ -238,29 +305,13 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, url),
}); });
} }
return; return;
} }
const node = await this.workspace.database
.selectFrom('nodes')
.selectAll()
.where('id', '=', state.id)
.executeTakeFirst();
if (!node) {
return;
}
const file = mapNode(node) as LocalFileNode;
if (node.server_revision === '0') {
// file is not synced with the server, we need to wait for the sync to complete
return;
}
if (file.attributes.status === FileStatus.Ready) { if (file.attributes.status === FileStatus.Ready) {
const updatedFileState = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('file_states') .updateTable('file_states')
@@ -278,20 +329,13 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, url),
}); });
} }
return; return;
} }
const filePath = this.buildFilePath(file.id, file.attributes.extension);
const exists = await this.app.fs.exists(filePath);
if (!exists) {
debug(`File ${file.id} not found`);
return;
}
try { try {
const fileStream = await this.app.fs.readStream(filePath); const fileStream = await this.app.fs.readStream(filePath);
@@ -322,7 +366,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(finalFileState), fileState: mapFileState(finalFileState, url),
}); });
} }
@@ -342,7 +386,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, url),
}); });
} }
} }
@@ -394,7 +438,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, null),
}); });
} }
@@ -421,6 +465,8 @@ export class FileService {
const filePath = this.buildFilePath(file.id, file.attributes.extension); const filePath = this.buildFilePath(file.id, file.attributes.extension);
const exists = await this.app.fs.exists(filePath); const exists = await this.app.fs.exists(filePath);
if (exists) { if (exists) {
const url = await this.app.fs.url(filePath);
const updatedFileState = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('file_states') .updateTable('file_states')
.returningAll() .returningAll()
@@ -437,7 +483,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, url),
}); });
} }
@@ -468,7 +514,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, null),
}); });
}, },
} }
@@ -476,6 +522,7 @@ export class FileService {
const writeStream = await this.app.fs.writeStream(filePath); const writeStream = await this.app.fs.writeStream(filePath);
await response.body?.pipeTo(writeStream); await response.body?.pipeTo(writeStream);
const url = await this.app.fs.url(filePath);
const updatedFileState = await this.workspace.database const updatedFileState = await this.workspace.database
.updateTable('file_states') .updateTable('file_states')
@@ -493,7 +540,7 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, url),
}); });
} }
} catch { } catch {
@@ -509,13 +556,115 @@ export class FileService {
type: 'file.state.updated', type: 'file.state.updated',
accountId: this.workspace.accountId, accountId: this.workspace.accountId,
workspaceId: this.workspace.id, workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState), fileState: mapFileState(updatedFileState, null),
}); });
} }
} }
} }
public async cleanDeletedFiles(): Promise<void> { private buildFilePath(id: string, extension: string): string {
return this.app.path.join(this.filesDir, `${id}${extension}`);
}
private async processSaveAsync(save: FileSaveState): Promise<void> {
if (this.app.meta.type !== 'desktop') {
return;
}
try {
const fileState = await this.workspace.database
.selectFrom('file_states')
.selectAll()
.where('id', '=', save.file.id)
.executeTakeFirst();
// if file is already downloaded, copy it to the save path
if (fileState && fileState.download_progress === 100) {
const sourceFilePath = this.buildFilePath(
save.file.id,
save.file.attributes.extension
);
await this.app.fs.copy(sourceFilePath, save.path);
save.status = SaveStatus.Completed;
save.completedAt = new Date().toISOString();
save.progress = 100;
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: save,
});
return;
}
// if file is not downloaded, download it
try {
const response = await this.workspace.account.client.get(
`v1/workspaces/${this.workspace.id}/files/${save.file.id}`,
{
onDownloadProgress: async (progress, _chunk) => {
const percent = Math.round((progress.percent || 0) * 100);
save.progress = percent;
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: save,
});
},
}
);
const writeStream = await this.app.fs.writeStream(save.path);
await response.body?.pipeTo(writeStream);
save.status = SaveStatus.Completed;
save.completedAt = new Date().toISOString();
save.progress = 100;
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: save,
});
} catch {
save.status = SaveStatus.Failed;
save.completedAt = new Date().toISOString();
save.progress = 0;
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: save,
});
}
} catch (error) {
debug(`Error saving file ${save.file.id}: ${error}`);
save.status = SaveStatus.Failed;
save.completedAt = new Date().toISOString();
save.progress = 0;
eventBus.publish({
type: 'file.save.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileSave: save,
});
}
}
private async cleanupFiles(): Promise<void> {
await this.cleanDeletedFiles();
await this.cleanOldDownloadedFiles();
}
private async cleanDeletedFiles(): Promise<void> {
debug(`Checking deleted files for workspace ${this.workspace.id}`); debug(`Checking deleted files for workspace ${this.workspace.id}`);
const fsFiles = await this.app.fs.listFiles(this.filesDir); const fsFiles = await this.app.fs.listFiles(this.filesDir);
@@ -547,7 +696,135 @@ export class FileService {
} }
} }
private buildFilePath(id: string, extension: string): string { private async cleanOldDownloadedFiles(): Promise<void> {
return this.app.path.join(this.filesDir, `${id}${extension}`); debug(`Cleaning old downloaded files for workspace ${this.workspace.id}`);
const sevenDaysAgo = new Date(Date.now() - ms('7 days')).toISOString();
let lastId = '';
const batchSize = 100;
let hasMoreFiles = true;
while (hasMoreFiles) {
let query = this.workspace.database
.selectFrom('file_states')
.select(['id', 'upload_status', 'download_completed_at'])
.where('download_status', '=', DownloadStatus.Completed)
.where('download_progress', '=', 100)
.where('download_completed_at', '<', sevenDaysAgo)
.orderBy('id', 'asc')
.limit(batchSize);
if (lastId) {
query = query.where('id', '>', lastId);
}
const fileStates = await query.execute();
if (fileStates.length === 0) {
hasMoreFiles = false;
continue;
}
const fileIds = fileStates.map((f) => f.id);
const fileInteractions = await this.workspace.database
.selectFrom('node_interactions')
.select(['node_id', 'last_opened_at'])
.where('node_id', 'in', fileIds)
.where('collaborator_id', '=', this.workspace.userId)
.execute();
const nodes = await this.workspace.database
.selectFrom('nodes')
.select(['id', 'attributes'])
.where('id', 'in', fileIds)
.execute();
const interactionMap = new Map(
fileInteractions.map((fi) => [fi.node_id, fi.last_opened_at])
);
const nodeMap = new Map(nodes.map((n) => [n.id, n.attributes]));
for (const fileState of fileStates) {
try {
const lastOpenedAt = interactionMap.get(fileState.id);
const shouldDelete = !lastOpenedAt || lastOpenedAt < sevenDaysAgo;
if (!shouldDelete) {
continue;
}
const nodeAttributes = nodeMap.get(fileState.id);
if (!nodeAttributes) {
continue;
}
const attributes = JSON.parse(nodeAttributes);
if (attributes.type !== 'file' || !attributes.extension) {
continue;
}
const filePath = this.buildFilePath(
fileState.id,
attributes.extension
);
const exists = await this.app.fs.exists(filePath);
if (!exists) {
continue;
}
debug(`Deleting old downloaded file: ${fileState.id}`);
await this.app.fs.delete(filePath);
if (
fileState.upload_status !== null &&
fileState.upload_status !== UploadStatus.None
) {
const updatedFileState = await this.workspace.database
.updateTable('file_states')
.returningAll()
.set({
download_status: DownloadStatus.None,
download_progress: 0,
download_completed_at: null,
})
.where('id', '=', fileState.id)
.executeTakeFirst();
if (updatedFileState) {
eventBus.publish({
type: 'file.state.updated',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileState: mapFileState(updatedFileState, null),
});
}
} else {
const deleted = await this.workspace.database
.deleteFrom('file_states')
.returningAll()
.where('id', '=', fileState.id)
.executeTakeFirst();
if (deleted) {
eventBus.publish({
type: 'file.state.deleted',
accountId: this.workspace.accountId,
workspaceId: this.workspace.id,
fileId: fileState.id,
});
}
}
} catch {
continue;
}
}
lastId = fileStates[fileStates.length - 1]!.id;
if (fileStates.length < batchSize) {
hasMoreFiles = false;
}
}
} }
} }

View File

@@ -5,7 +5,7 @@ import {
DocumentState, DocumentState,
DocumentUpdate, DocumentUpdate,
} from '@colanode/client/types/documents'; } from '@colanode/client/types/documents';
import { FileState } from '@colanode/client/types/files'; import { FileSaveState, FileState } from '@colanode/client/types/files';
import { import {
LocalNode, LocalNode,
NodeCounter, NodeCounter,
@@ -91,6 +91,20 @@ export type FileStateUpdatedEvent = {
fileState: FileState; fileState: FileState;
}; };
export type FileStateDeletedEvent = {
type: 'file.state.deleted';
accountId: string;
workspaceId: string;
fileId: string;
};
export type FileSaveUpdatedEvent = {
type: 'file.save.updated';
accountId: string;
workspaceId: string;
fileSave: FileSaveState;
};
export type AccountCreatedEvent = { export type AccountCreatedEvent = {
type: 'account.created'; type: 'account.created';
account: Account; account: Account;
@@ -309,6 +323,8 @@ export type Event =
| ServerDeletedEvent | ServerDeletedEvent
| ServerAvailabilityChangedEvent | ServerAvailabilityChangedEvent
| FileStateUpdatedEvent | FileStateUpdatedEvent
| FileStateDeletedEvent
| FileSaveUpdatedEvent
| QueryResultUpdatedEvent | QueryResultUpdatedEvent
| RadarDataUpdatedEvent | RadarDataUpdatedEvent
| CollaborationCreatedEvent | CollaborationCreatedEvent

View File

@@ -1,3 +1,4 @@
import { LocalFileNode } from '@colanode/client/types';
import { FileSubtype } from '@colanode/core'; import { FileSubtype } from '@colanode/core';
export type OpenFileDialogOptions = { export type OpenFileDialogOptions = {
@@ -29,6 +30,7 @@ export type FileState = {
uploadRetries: number | null; uploadRetries: number | null;
uploadStartedAt: string | null; uploadStartedAt: string | null;
uploadCompletedAt: string | null; uploadCompletedAt: string | null;
url: string | null;
}; };
export enum DownloadStatus { export enum DownloadStatus {
@@ -44,3 +46,19 @@ export enum UploadStatus {
Completed = 2, Completed = 2,
Failed = 3, Failed = 3,
} }
export enum SaveStatus {
Active = 1,
Completed = 2,
Failed = 3,
}
export type FileSaveState = {
id: string;
file: LocalFileNode;
status: SaveStatus;
startedAt: string;
completedAt: string | null;
path: string;
progress: number;
};

View File

@@ -40,6 +40,7 @@ export enum IdType {
Window = 'wi', Window = 'wi',
TempFile = 'tf', TempFile = 'tf',
Socket = 'sk', Socket = 'sk',
Save = 'sv',
} }
export const generateId = (type: IdType): string => { export const generateId = (type: IdType): string => {

View File

@@ -0,0 +1,60 @@
import { Check, X } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@colanode/ui/components/ui/tooltip';
interface DownloadStatusProps {
status: SaveStatus;
progress: number;
}
export const DownloadStatus = ({ status, progress }: DownloadStatusProps) => {
switch (status) {
case SaveStatus.Active:
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center justify-center p-1">
<Spinner className="size-5 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Downloading ... {progress}%
</TooltipContent>
</Tooltip>
);
case SaveStatus.Completed:
return (
<Tooltip>
<TooltipTrigger>
<div className="bg-green-500 rounded-full p-1 flex items-center justify-center">
<Check className="size-4 text-white" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Downloaded
</TooltipContent>
</Tooltip>
);
case SaveStatus.Failed:
return (
<Tooltip>
<TooltipTrigger>
<div className="bg-red-500 rounded-full p-1 flex items-center justify-center">
<X className="size-4 text-white" />
</div>
</TooltipTrigger>
<TooltipContent className="flex flex-row items-center gap-2">
Download failed
</TooltipContent>
</Tooltip>
);
default:
return null;
}
};

View File

@@ -0,0 +1,22 @@
import { Download } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
} from '@colanode/ui/components/ui/breadcrumb';
export const DownloadsBreadcrumb = () => {
return (
<Breadcrumb className="flex-grow">
<BreadcrumbList>
<BreadcrumbItem className="cursor-pointer hover:text-foreground">
<div className="flex items-center space-x-2">
<Download className="size-5" />
<span>Downloads</span>
</div>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,30 @@
import { Download } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
export const DownloadsContainerTab = () => {
const workspace = useWorkspace();
const fileSaveListQuery = useQuery({
type: 'file.save.list',
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (fileSaveListQuery.isPending) {
return <p className="text-sm text-muted-foreground">Loading...</p>;
}
const activeSaves =
fileSaveListQuery.data?.filter((save) => save.status === SaveStatus.Active)
?.length ?? 0;
return (
<div className="flex items-center space-x-2">
<Download className="size-5" />
<span>Downloads {activeSaves > 0 ? `(${activeSaves})` : ''}</span>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { DownloadsBreadcrumb } from '@colanode/ui/components/downloads/downloads-breadcrumb';
import { DownloadsList } from '@colanode/ui/components/downloads/downloads-list';
import {
Container,
ContainerBody,
ContainerHeader,
} from '@colanode/ui/components/ui/container';
export const DownloadsContainer = () => {
return (
<Container>
<ContainerHeader>
<DownloadsBreadcrumb />
</ContainerHeader>
<ContainerBody>
<DownloadsList />
</ContainerBody>
</Container>
);
};

View File

@@ -0,0 +1,80 @@
import { Folder } from 'lucide-react';
import { SaveStatus } from '@colanode/client/types';
import { DownloadStatus } from '@colanode/ui/components/downloads/download-status';
import { FileThumbnail } from '@colanode/ui/components/files/file-thumbnail';
import { Button } from '@colanode/ui/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@colanode/ui/components/ui/tooltip';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useQuery } from '@colanode/ui/hooks/use-query';
export const DownloadsList = () => {
const workspace = useWorkspace();
const fileSaveListQuery = useQuery({
type: 'file.save.list',
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const saves = fileSaveListQuery.data || [];
const handleOpenDirectory = (path: string) => {
window.colanode.showItemInFolder(path);
};
if (saves.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No downloads yet
</div>
);
}
return (
<div className="px-10 py-4 max-w-[50rem] flex flex-col gap-4">
{saves.map((save) => (
<div
key={save.id}
className="border rounded-lg p-4 bg-card hover:bg-accent/50 transition-colors flex items-center gap-6"
>
<FileThumbnail
file={save.file}
className="size-10 text-muted-foreground"
/>
<div className="flex-1 flex flex-col gap-1 justify-center items-start">
<p className="font-medium text-sm truncate">
{save.file.attributes.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{save.path}
</p>
</div>
<div className="flex items-center gap-2">
{save.status === SaveStatus.Completed && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDirectory(save.path)}
className="h-8 w-8 p-0"
>
<Folder className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Show in folder</TooltipContent>
</Tooltip>
)}
<DownloadStatus status={save.status} progress={save.progress} />
</div>
</div>
))}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import { LocalFileNode } from '@colanode/client/types';
import { FilePreview } from '@colanode/ui/components/files/file-preview'; import { FilePreview } from '@colanode/ui/components/files/file-preview';
import { useLayout } from '@colanode/ui/contexts/layout'; import { useLayout } from '@colanode/ui/contexts/layout';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
import { useQuery } from '@colanode/ui/hooks/use-query'; import { useQuery } from '@colanode/ui/hooks/use-query';
interface FileBlockProps { interface FileBlockProps {
@@ -18,6 +19,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
useNodeRadar(nodeGetQuery.data);
if (nodeGetQuery.isPending || !nodeGetQuery.data) { if (nodeGetQuery.isPending || !nodeGetQuery.data) {
return null; return null;

View File

@@ -1,5 +1,7 @@
import { LocalFileNode } from '@colanode/client/types'; import { LocalFileNode } from '@colanode/client/types';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreview } from '@colanode/ui/components/files/file-preview'; import { FilePreview } from '@colanode/ui/components/files/file-preview';
import { FileSaveButton } from '@colanode/ui/components/files/file-save-button';
import { FileSidebar } from '@colanode/ui/components/files/file-sidebar'; import { FileSidebar } from '@colanode/ui/components/files/file-sidebar';
interface FileBodyProps { interface FileBodyProps {
@@ -7,10 +9,21 @@ interface FileBodyProps {
} }
export const FileBody = ({ file }: FileBodyProps) => { export const FileBody = ({ file }: FileBodyProps) => {
const canPreview =
file.attributes.subtype === 'image' || file.attributes.subtype === 'video';
return ( return (
<div className="flex h-full max-h-full w-full flex-row items-center gap-2"> <div className="flex h-full max-h-full w-full flex-row items-center gap-2">
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10"> <div className="flex w-full max-w-full h-full flex-grow items-center justify-center overflow-hidden p-10 relative">
<FilePreview file={file} /> <div className="absolute top-4 right-4 z-10">
<FileSaveButton file={file} />
</div>
{canPreview ? (
<FilePreview file={file} />
) : (
<FileNoPreview mimeType={file.attributes.mimeType} />
)}
</div> </div>
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3"> <div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
<FileSidebar file={file} /> <FileSidebar file={file} />

View File

@@ -10,6 +10,7 @@ import {
ContainerSettings, ContainerSettings,
} from '@colanode/ui/components/ui/container'; } from '@colanode/ui/components/ui/container';
import { useNodeContainer } from '@colanode/ui/hooks/use-node-container'; import { useNodeContainer } from '@colanode/ui/hooks/use-node-container';
import { useNodeRadar } from '@colanode/ui/hooks/use-node-radar';
interface FileContainerProps { interface FileContainerProps {
fileId: string; fileId: string;
@@ -17,6 +18,7 @@ interface FileContainerProps {
export const FileContainer = ({ fileId }: FileContainerProps) => { export const FileContainer = ({ fileId }: FileContainerProps) => {
const data = useNodeContainer<LocalFileNode>(fileId); const data = useNodeContainer<LocalFileNode>(fileId);
useNodeRadar(data.node);
if (data.isPending) { if (data.isPending) {
return null; return null;

View File

@@ -0,0 +1,36 @@
import { Download } from 'lucide-react';
import { FileState } from '@colanode/client/types';
import { Spinner } from '@colanode/ui/components/ui/spinner';
interface FileDownloadProgressProps {
state: FileState | null | undefined;
}
export const FileDownloadProgress = ({ state }: FileDownloadProgressProps) => {
const progress = state?.downloadProgress || 0;
const showProgress = progress > 0;
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<div className="relative">
<Spinner className="size-20 text-muted-foreground stroke-1" />
<div className="absolute inset-0 flex items-center justify-center">
<Download className="size-6 animate-pulse" />
</div>
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">
Downloading file
</p>
{showProgress && (
<p className="mt-1 text-xs text-muted-foreground">
{Math.round(progress)}%
</p>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,63 +0,0 @@
import { Download } from 'lucide-react';
import { toast } from 'sonner';
import {
DownloadStatus,
FileState,
LocalFileNode,
} from '@colanode/client/types';
import { formatBytes } from '@colanode/core';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface FileDownloadProps {
file: LocalFileNode;
state: FileState | null | undefined;
}
export const FileDownload = ({ file, state }: FileDownloadProps) => {
const workspace = useWorkspace();
const isDownloading = state?.downloadStatus === DownloadStatus.Pending;
return (
<div className="flex h-full w-full items-center justify-center">
{isDownloading ? (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Spinner className="size-8" />
<p className="text-sm">
Downloading file ({state?.downloadProgress}%)
</p>
</div>
) : (
<div
className="flex cursor-pointer flex-col items-center gap-3 text-muted-foreground hover:text-primary"
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
const result = await window.colanode.executeMutation({
type: 'file.download',
accountId: workspace.accountId,
workspaceId: workspace.id,
fileId: file.id,
});
if (!result.success) {
toast.error(result.error.message);
}
}}
>
<Download className="size-8" />
<p className="text-sm">
File is not downloaded in your device. Click to download.
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(file.attributes.size)} -{' '}
{file.attributes.mimeType.split('/')[1]}
</p>
</div>
)}
</div>
);
};

View File

@@ -1,11 +1,11 @@
import { formatMimeType } from '@colanode/core'; import { formatMimeType } from '@colanode/core';
import { FileIcon } from '@colanode/ui/components/files/file-icon'; import { FileIcon } from '@colanode/ui/components/files/file-icon';
interface FilePreviewOtherProps { interface FileNoPreviewProps {
mimeType: string; mimeType: string;
} }
export const FilePreviewOther = ({ mimeType }: FilePreviewOtherProps) => { export const FileNoPreview = ({ mimeType }: FileNoPreviewProps) => {
return ( return (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<FileIcon mimeType={mimeType} className="h-10 w-10" /> <FileIcon mimeType={mimeType} className="h-10 w-10" />

View File

@@ -1,11 +1,12 @@
import { match } from 'ts-pattern'; import { useEffect } from 'react';
import { LocalFileNode } from '@colanode/client/types'; import { DownloadStatus, LocalFileNode } from '@colanode/client/types';
import { FileDownload } from '@colanode/ui/components/files/file-download'; import { FileDownloadProgress } from '@colanode/ui/components/files/file-download-progress';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image'; import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image';
import { FilePreviewOther } from '@colanode/ui/components/files/previews/file-preview-other';
import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video'; import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { useQuery } from '@colanode/ui/hooks/use-query'; import { useQuery } from '@colanode/ui/hooks/use-query';
interface FilePreviewProps { interface FilePreviewProps {
@@ -14,6 +15,7 @@ interface FilePreviewProps {
export const FilePreview = ({ file }: FilePreviewProps) => { export const FilePreview = ({ file }: FilePreviewProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const mutation = useMutation();
const fileStateQuery = useQuery({ const fileStateQuery = useQuery({
type: 'file.state.get', type: 'file.state.get',
@@ -22,37 +24,56 @@ export const FilePreview = ({ file }: FilePreviewProps) => {
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
const shouldFetchFileUrl = fileStateQuery.data?.downloadProgress === 100; const isDownloading =
fileStateQuery.data?.downloadStatus === DownloadStatus.Pending;
const isDownloaded =
fileStateQuery.data?.downloadStatus === DownloadStatus.Completed;
const fileUrlGetQuery = useQuery( useEffect(() => {
{ if (!fileStateQuery.isPending && !isDownloaded && !isDownloading) {
type: 'file.url.get', mutation.mutate({
id: file.id, input: {
extension: file.attributes.extension, type: 'file.download',
accountId: workspace.accountId, accountId: workspace.accountId,
workspaceId: workspace.id, workspaceId: workspace.id,
}, fileId: file.id,
{ path: null,
enabled: shouldFetchFileUrl, },
onError: (error) => {
console.error('Failed to start file download:', error.message);
},
});
} }
); }, [
fileStateQuery.isPending,
isDownloaded,
isDownloading,
mutation,
workspace.accountId,
workspace.id,
file.id,
]);
if ( if (fileStateQuery.isPending) {
fileStateQuery.isPending ||
(shouldFetchFileUrl && fileUrlGetQuery.isPending)
) {
return null; return null;
} }
const url = fileUrlGetQuery.data?.url; if (isDownloading) {
if (fileStateQuery.data?.downloadProgress !== 100 || !url) { return <FileDownloadProgress state={fileStateQuery.data} />;
return <FileDownload file={file} state={fileStateQuery.data} />;
} }
return match(file.attributes.subtype) const url = fileStateQuery.data?.url;
.with('image', () => ( if (!url) {
<FilePreviewImage url={url} name={file.attributes.name} /> return <FileNoPreview mimeType={file.attributes.mimeType} />;
)) }
.with('video', () => <FilePreviewVideo url={url} />)
.otherwise(() => <FilePreviewOther mimeType={file.attributes.mimeType} />); if (file.attributes.subtype === 'image') {
return <FilePreviewImage url={url} name={file.attributes.name} />;
}
if (file.attributes.subtype === 'video') {
return <FilePreviewVideo url={url} />;
}
return <FileNoPreview mimeType={file.attributes.mimeType} />;
}; };

View File

@@ -0,0 +1,144 @@
import { Download } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { LocalFileNode } from '@colanode/client/types';
import { Button } from '@colanode/ui/components/ui/button';
import { Spinner } from '@colanode/ui/components/ui/spinner';
import { useApp } from '@colanode/ui/contexts/app';
import { useLayout } from '@colanode/ui/contexts/layout';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { useQuery } from '@colanode/ui/hooks/use-query';
interface FileSaveButtonProps {
file: LocalFileNode;
}
export const FileSaveButton = ({ file }: FileSaveButtonProps) => {
const app = useApp();
const workspace = useWorkspace();
const mutation = useMutation();
const layout = useLayout();
const [isSaving, setIsSaving] = useState(false);
const fileStateQuery = useQuery({
type: 'file.state.get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
const handleDownloadDesktop = async () => {
const path = await window.colanode.showFileSaveDialog({
name: file.attributes.name,
});
if (!path) {
return;
}
mutation.mutate({
input: {
type: 'file.save',
accountId: workspace.accountId,
workspaceId: workspace.id,
fileId: file.id,
path,
},
onSuccess: () => {
layout.open('downloads');
},
onError: () => {
toast.error('Failed to save file');
},
});
};
const handleDownloadWeb = async () => {
if (fileStateQuery.isPending) {
return;
}
setIsSaving(true);
try {
const url = fileStateQuery.data?.url;
if (url) {
// the file is already downloaded locally, so we can just trigger a download
const link = document.createElement('a');
link.href = url;
link.download = file.attributes.name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
// the file is not downloaded locally, so we need to download it
const request = await window.colanode.executeQuery({
type: 'file.download.request.get',
id: file.id,
accountId: workspace.accountId,
workspaceId: workspace.id,
});
if (!request) {
toast.error('Failed to save file');
return;
}
const response = await fetch(request.url, {
method: 'GET',
headers: request.headers,
});
if (!response.ok) {
toast.error('Failed to save file');
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.attributes.name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
}
} catch {
toast.error('Failed to save file');
} finally {
setIsSaving(false);
}
};
const handleDownload = () => {
if (app.type === 'desktop') {
handleDownloadDesktop();
} else if (app.type === 'web') {
handleDownloadWeb();
}
};
return (
<Button
variant="outline"
onClick={handleDownload}
disabled={fileStateQuery.isPending || isSaving}
>
{isSaving ? (
<Spinner className="size-4" />
) : (
<Download className="size-4" />
)}
Save
</Button>
);
};

View File

@@ -19,29 +19,14 @@ export const FileThumbnail = ({ file, className }: FileThumbnailProps) => {
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
const fileUrlGetQuery = useQuery(
{
type: 'file.url.get',
id: file.id,
extension: file.attributes.extension,
accountId: workspace.accountId,
workspaceId: workspace.id,
},
{
enabled: fileStateGetQuery.data?.downloadProgress === 100,
}
);
const url = fileUrlGetQuery.data?.url;
if ( if (
file.attributes.subtype === 'image' && file.attributes.subtype === 'image' &&
fileStateGetQuery.data?.downloadProgress === 100 && fileStateGetQuery.data?.downloadProgress === 100 &&
url fileStateGetQuery.data?.url
) { ) {
return ( return (
<img <img
src={url} src={fileStateGetQuery.data?.url}
alt={file.attributes.name} alt={file.attributes.name}
className={cn('object-contain object-center', className)} className={cn('object-contain object-center', className)}
/> />

View File

@@ -5,6 +5,7 @@ import { getIdType, IdType } from '@colanode/core';
import { ChannelContainer } from '@colanode/ui/components/channels/channel-container'; import { ChannelContainer } from '@colanode/ui/components/channels/channel-container';
import { ChatContainer } from '@colanode/ui/components/chats/chat-container'; import { ChatContainer } from '@colanode/ui/components/chats/chat-container';
import { DatabaseContainer } from '@colanode/ui/components/databases/database-container'; import { DatabaseContainer } from '@colanode/ui/components/databases/database-container';
import { DownloadsList } from '@colanode/ui/components/downloads/downloads-list';
import { FileContainer } from '@colanode/ui/components/files/file-container'; import { FileContainer } from '@colanode/ui/components/files/file-container';
import { FolderContainer } from '@colanode/ui/components/folders/folder-container'; import { FolderContainer } from '@colanode/ui/components/folders/folder-container';
import { MessageContainer } from '@colanode/ui/components/messages/message-container'; import { MessageContainer } from '@colanode/ui/components/messages/message-container';
@@ -17,6 +18,24 @@ interface ContainerTabContentProps {
tab: ContainerTab; tab: ContainerTab;
} }
const ContainerTabContentBody = ({ tab }: ContainerTabContentProps) => {
if (tab.path === 'downloads') {
return <DownloadsList />;
}
return match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainer spaceId={tab.path} />)
.with(IdType.Channel, () => <ChannelContainer channelId={tab.path} />)
.with(IdType.Page, () => <PageContainer pageId={tab.path} />)
.with(IdType.Database, () => <DatabaseContainer databaseId={tab.path} />)
.with(IdType.Record, () => <RecordContainer recordId={tab.path} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.path} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.path} />)
.with(IdType.File, () => <FileContainer fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainer messageId={tab.path} />)
.otherwise(() => null);
};
export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => { export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
return ( return (
<TabsContent <TabsContent
@@ -24,19 +43,7 @@ export const ContainerTabContent = ({ tab }: ContainerTabContentProps) => {
key={tab.path} key={tab.path}
className="h-full min-h-full w-full min-w-full m-0 pt-2" className="h-full min-h-full w-full min-w-full m-0 pt-2"
> >
{match(getIdType(tab.path)) <ContainerTabContentBody tab={tab} />
.with(IdType.Space, () => <SpaceContainer spaceId={tab.path} />)
.with(IdType.Channel, () => <ChannelContainer channelId={tab.path} />)
.with(IdType.Page, () => <PageContainer pageId={tab.path} />)
.with(IdType.Database, () => (
<DatabaseContainer databaseId={tab.path} />
))
.with(IdType.Record, () => <RecordContainer recordId={tab.path} />)
.with(IdType.Chat, () => <ChatContainer chatId={tab.path} />)
.with(IdType.Folder, () => <FolderContainer folderId={tab.path} />)
.with(IdType.File, () => <FileContainer fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainer messageId={tab.path} />)
.otherwise(() => null)}
</TabsContent> </TabsContent>
); );
}; };

View File

@@ -8,6 +8,7 @@ import { getIdType, IdType } from '@colanode/core';
import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab'; import { ChannelContainerTab } from '@colanode/ui/components/channels/channel-container-tab';
import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab'; import { ChatContainerTab } from '@colanode/ui/components/chats/chat-container-tab';
import { DatabaseContainerTab } from '@colanode/ui/components/databases/database-container-tab'; import { DatabaseContainerTab } from '@colanode/ui/components/databases/database-container-tab';
import { DownloadsContainerTab } from '@colanode/ui/components/downloads/downloads-container-tab';
import { FileContainerTab } from '@colanode/ui/components/files/file-container-tab'; import { FileContainerTab } from '@colanode/ui/components/files/file-container-tab';
import { FolderContainerTab } from '@colanode/ui/components/folders/folder-container-tab'; import { FolderContainerTab } from '@colanode/ui/components/folders/folder-container-tab';
import { MessageContainerTab } from '@colanode/ui/components/messages/message-container-tab'; import { MessageContainerTab } from '@colanode/ui/components/messages/message-container-tab';
@@ -24,6 +25,31 @@ interface ContainerTabTriggerProps {
onMove: (before: string | null) => void; onMove: (before: string | null) => void;
} }
const ContainerTabTriggerContent = ({ tab }: { tab: ContainerTab }) => {
if (tab.path === 'downloads') {
return <DownloadsContainerTab />;
}
return match(getIdType(tab.path))
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (
<ChannelContainerTab
channelId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.path} />)
.with(IdType.Database, () => <DatabaseContainerTab databaseId={tab.path} />)
.with(IdType.Record, () => <RecordContainerTab recordId={tab.path} />)
.with(IdType.Chat, () => (
<ChatContainerTab chatId={tab.path} isActive={tab.active ?? false} />
))
.with(IdType.Folder, () => <FolderContainerTab folderId={tab.path} />)
.with(IdType.File, () => <FileContainerTab fileId={tab.path} />)
.with(IdType.Message, () => <MessageContainerTab messageId={tab.path} />)
.otherwise(() => null);
};
export const ContainerTabTrigger = ({ export const ContainerTabTrigger = ({
tab, tab,
onClose, onClose,
@@ -83,34 +109,10 @@ export const ContainerTabTrigger = ({
onOpen(); onOpen();
} }
}} }}
ref={dragDropRef as React.LegacyRef<HTMLButtonElement>} ref={dragDropRef as React.RefAttributes<HTMLButtonElement>['ref']}
> >
<div className="overflow-hidden truncate"> <div className="overflow-hidden truncate">
{match(getIdType(tab.path)) <ContainerTabTriggerContent tab={tab} />
.with(IdType.Space, () => <SpaceContainerTab spaceId={tab.path} />)
.with(IdType.Channel, () => (
<ChannelContainerTab
channelId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Page, () => <PageContainerTab pageId={tab.path} />)
.with(IdType.Database, () => (
<DatabaseContainerTab databaseId={tab.path} />
))
.with(IdType.Record, () => <RecordContainerTab recordId={tab.path} />)
.with(IdType.Chat, () => (
<ChatContainerTab
chatId={tab.path}
isActive={tab.active ?? false}
/>
))
.with(IdType.Folder, () => <FolderContainerTab folderId={tab.path} />)
.with(IdType.File, () => <FileContainerTab fileId={tab.path} />)
.with(IdType.Message, () => (
<MessageContainerTab messageId={tab.path} />
))
.otherwise(() => null)}
</div> </div>
<div <div
className="opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 transition-opacity duration-200 flex-shrink-0 cursor-pointer" className="opacity-0 group-hover/tab:opacity-100 group-data-[state=active]/tab:opacity-100 transition-opacity duration-200 flex-shrink-0 cursor-pointer"

View File

@@ -18,7 +18,6 @@ interface PageContainerProps {
export const PageContainer = ({ pageId }: PageContainerProps) => { export const PageContainer = ({ pageId }: PageContainerProps) => {
const data = useNodeContainer<LocalPageNode>(pageId); const data = useNodeContainer<LocalPageNode>(pageId);
useNodeRadar(data.node); useNodeRadar(data.node);
if (data.isPending) { if (data.isPending) {

View File

@@ -4,8 +4,8 @@ import { X } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { TempFile } from '@colanode/client/types'; import { TempFile } from '@colanode/client/types';
import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview';
import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image'; import { FilePreviewImage } from '@colanode/ui/components/files/previews/file-preview-image';
import { FilePreviewOther } from '@colanode/ui/components/files/previews/file-preview-other';
import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video'; import { FilePreviewVideo } from '@colanode/ui/components/files/previews/file-preview-video';
export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => { export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
@@ -34,8 +34,9 @@ export const TempFileNodeView = ({ node, deleteNode }: NodeViewProps) => {
{match(type) {match(type)
.with('image', () => <FilePreviewImage url={file.url} name={name} />) .with('image', () => <FilePreviewImage url={file.url} name={name} />)
.with('video', () => <FilePreviewVideo url={file.url} />) .with('video', () => <FilePreviewVideo url={file.url} />)
.with('other', () => <FilePreviewOther mimeType={mimeType} />) .otherwise(() => (
.otherwise(() => null)} <FileNoPreview mimeType={mimeType} />
))}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );

View File

@@ -4,7 +4,7 @@ import { Node } from '@colanode/core';
import { useRadar } from '@colanode/ui/contexts/radar'; import { useRadar } from '@colanode/ui/contexts/radar';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
export const useNodeRadar = (node: Node | null) => { export const useNodeRadar = (node: Node | null | undefined) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const radar = useRadar(); const radar = useRadar();

View File

@@ -3,6 +3,10 @@ import { MutationInput, MutationResult } from '@colanode/client/mutations';
import { QueryInput, QueryMap } from '@colanode/client/queries'; import { QueryInput, QueryMap } from '@colanode/client/queries';
import { TempFile } from '@colanode/client/types'; import { TempFile } from '@colanode/client/types';
interface SaveDialogOptions {
name: string;
}
export interface ColanodeWindowApi { export interface ColanodeWindowApi {
init: () => Promise<void>; init: () => Promise<void>;
executeMutation: <T extends MutationInput>( executeMutation: <T extends MutationInput>(
@@ -18,6 +22,10 @@ export interface ColanodeWindowApi {
unsubscribeQuery: (key: string) => Promise<void>; unsubscribeQuery: (key: string) => Promise<void>;
saveTempFile: (file: File) => Promise<TempFile>; saveTempFile: (file: File) => Promise<TempFile>;
openExternalUrl: (url: string) => Promise<void>; openExternalUrl: (url: string) => Promise<void>;
showItemInFolder: (path: string) => Promise<void>;
showFileSaveDialog: (
options: SaveDialogOptions
) => Promise<string | undefined>;
} }
declare global { declare global {