From 441af861832778b7e5ac0362e17d33fe463eb6e4 Mon Sep 17 00:00:00 2001 From: Casper Jeukendrup <48658420+cbjeukendrup@users.noreply.github.com> Date: Mon, 27 Oct 2025 02:48:06 +0100 Subject: [PATCH 1/5] Add context menu for folder items in changed files tree Fix typo: succeded -> succeeded --- .../src/components/FileContextMenu.svelte | 39 +- apps/desktop/src/components/FileList.svelte | 11 +- .../src/components/FileTreeNode.svelte | 20 +- .../src/components/FolderContextMenu.svelte | 548 ++++++++++++++++++ .../src/components/TreeListFolder.svelte | 66 ++- .../lib/components/file/FolderListItem.svelte | 9 + 6 files changed, 646 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/components/FolderContextMenu.svelte diff --git a/apps/desktop/src/components/FileContextMenu.svelte b/apps/desktop/src/components/FileContextMenu.svelte index 3b2914461f..a35e64976b 100644 --- a/apps/desktop/src/components/FileContextMenu.svelte +++ b/apps/desktop/src/components/FileContextMenu.svelte @@ -32,7 +32,6 @@ chipToasts } from '@gitbutler/ui'; import { slugify } from '@gitbutler/ui/utils/string'; - import type { DiffSpec } from '$lib/hunks/hunk'; import type { SelectionId } from '$lib/selection/key'; type Props = { @@ -113,16 +112,9 @@ } async function confirmDiscard(item: FileItem) { - const worktreeChanges: DiffSpec[] = item.changes.map((change) => ({ - previousPathBytes: - change.status.type === 'Rename' ? change.status.subject.previousPathBytes : null, - pathBytes: change.pathBytes, - hunkHeaders: [] - })); - await stackService.discardChanges({ projectId, - worktreeChanges + worktreeChanges: changesToDiffSpec(item.changes) }); const selectedFiles = item.changes.map((change) => ({ ...selectionId, path: change.path })); @@ -139,17 +131,11 @@ if (!branchName) { return; } - const worktreeChanges: DiffSpec[] = item.changes.map((change) => ({ - previousPathBytes: - change.status.type === 'Rename' ? change.status.subject.previousPathBytes : null, - pathBytes: change.pathBytes, - hunkHeaders: [] - })); await stackService.stashIntoBranch({ projectId, branchName, - worktreeChanges + worktreeChanges: changesToDiffSpec(item.changes) }); stashConfirmationModal?.close(); @@ -191,17 +177,11 @@ } try { - await chipToasts.promise( - autoCommit({ - projectId, - changes - }), - { - loading: 'Started auto commit', - success: 'Auto commit succeded', - error: 'Auto commit failed' - } - ); + await chipToasts.promise(autoCommit({ projectId, changes }), { + loading: 'Started auto commit', + success: 'Auto commit succeeded', + error: 'Auto commit failed' + }); } catch (error) { console.error('Auto commit failed:', error); } @@ -216,7 +196,7 @@ try { await chipToasts.promise(branchChanges({ projectId, changes }), { loading: 'Creating a branch and committing changes', - success: 'Branching changes succeded', + success: 'Branching changes succeeded', error: 'Branching changes failed' }); } catch (error) { @@ -233,7 +213,7 @@ try { await chipToasts.promise(absorbChanges({ projectId, changes }), { loading: 'Looking for the best place to absorb the changes', - success: 'Absorbing changes succeded', + success: 'Absorbing changes succeeded', error: 'Absorbing changes failed' }); } catch (error) { @@ -296,7 +276,6 @@ } const branchName = selectionId.branchName; - const fileNames = changes.map((change) => change.path); try { diff --git a/apps/desktop/src/components/FileList.svelte b/apps/desktop/src/components/FileList.svelte index 990b8678a6..cef1ba2284 100644 --- a/apps/desktop/src/components/FileList.svelte +++ b/apps/desktop/src/components/FileList.svelte @@ -332,7 +332,16 @@ {@const node = abbreviateFolders(changesToFileTree(changes))} - + {:else} {#each node.children as childNode (childNode.name)} - + {/each} {:else if node.kind === 'file'} {@render fileTemplate(node.change, node.index, depth)} {:else} + import ReduxResult from '$components/ReduxResult.svelte'; + import { ACTION_SERVICE } from '$lib/actions/actionService.svelte'; + import { AI_SERVICE } from '$lib/ai/service'; + import { BACKEND } from '$lib/backend'; + import { CLIPBOARD_SERVICE } from '$lib/backend/clipboard'; + import { changesToDiffSpec } from '$lib/commits/utils'; + import { projectAiExperimentalFeaturesEnabled, projectAiGenEnabled } from '$lib/config/config'; + import { FILE_SERVICE } from '$lib/files/fileService'; + import { isTreeChange, type TreeChange } from '$lib/hunks/change'; + import { PROJECTS_SERVICE } from '$lib/project/projectsService'; + import { FILE_SELECTION_MANAGER } from '$lib/selection/fileSelectionManager.svelte'; + import { STACK_SERVICE } from '$lib/stacks/stackService.svelte'; + import { UI_STATE } from '$lib/state/uiState.svelte'; + import { inject } from '@gitbutler/core/context'; + + import { + AsyncButton, + Button, + ContextMenu, + ContextMenuItem, + ContextMenuItemSubmenu, + ContextMenuSection, + Modal, + Textbox, + chipToasts + } from '@gitbutler/ui'; + import { slugify } from '@gitbutler/ui/utils/string'; + import type { SelectionId } from '$lib/selection/key'; + + type Props = { + projectId: string; + stackId: string | undefined; + selectionId: SelectionId; + trigger?: HTMLElement; + editMode?: boolean; + }; + + type FolderItem = { + path: string; + changes: TreeChange[]; + }; + + function isFolderItem(item: unknown): item is FolderItem { + return ( + typeof item === 'object' && + item !== null && + 'path' in item && + typeof item.path === 'string' && + 'changes' in item && + Array.isArray(item.changes) && + item.changes.every(isTreeChange) + ); + } + + const { trigger, selectionId, stackId, projectId, editMode = false }: Props = $props(); + const stackService = inject(STACK_SERVICE); + const uiState = inject(UI_STATE); + const idSelection = inject(FILE_SELECTION_MANAGER); + const aiService = inject(AI_SERVICE); + const actionService = inject(ACTION_SERVICE); + const fileService = inject(FILE_SERVICE); + const clipboardService = inject(CLIPBOARD_SERVICE); + const backend = inject(BACKEND); + const [autoCommit, autoCommitting] = actionService.autoCommit; + const [branchChanges, branchingChanges] = actionService.branchChanges; + const [absorbChanges, absorbingChanges] = actionService.absorb; + const [splitOffChanges] = stackService.splitBranch; + const [splitBranchIntoDependentBranch] = stackService.splitBrancIntoDependentBranch; + + const projectService = inject(PROJECTS_SERVICE); + + const isUncommitted = $derived(selectionId.type === 'worktree'); + const isBranchFiles = $derived(selectionId.type === 'branch'); + const selectionBranchName = $derived( + selectionId.type === 'branch' ? selectionId.branchName : undefined + ); + + // Platform-specific label for "Show in Finder/Explorer/File Manager" + const showInFolderLabel = (() => { + switch (backend.platformName) { + case 'macos': + return 'Show in Finder'; + case 'windows': + return 'Show in Explorer'; + default: + return 'Show in File Manager'; + } + })(); + + let confirmationModal: ReturnType | undefined; + let stashConfirmationModal: ReturnType | undefined; + let contextMenu: ReturnType; + let aiConfigurationValid = $state(false); + + const aiGenEnabled = $derived(projectAiGenEnabled(projectId)); + const experimentalFeaturesEnabled = $derived(projectAiExperimentalFeaturesEnabled(projectId)); + + const canUseGBAI = $derived( + $aiGenEnabled && aiConfigurationValid && $experimentalFeaturesEnabled + ); + + async function confirmDiscard(item: FolderItem) { + await stackService.discardChanges({ + projectId, + worktreeChanges: changesToDiffSpec(item.changes) + }); + + const selectedFiles = item.changes.map((change) => ({ ...selectionId, path: change.path })); + + // Unselect the discarded files + idSelection.removeMany(selectedFiles); + + confirmationModal?.close(); + } + + let stashBranchName = $state(); + const slugifiedRefName = $derived(stashBranchName && slugify(stashBranchName)); + + async function confirmStashIntoBranch(item: FolderItem, branchName: string | undefined) { + if (!branchName) { + return; + } + + await stackService.stashIntoBranch({ + projectId, + branchName, + worktreeChanges: changesToDiffSpec(item.changes) + }); + + stashConfirmationModal?.close(); + } + + export function open(e: MouseEvent, item: FolderItem) { + contextMenu.open(e, item); + aiService.validateGitButlerAPIConfiguration().then((value) => { + aiConfigurationValid = value; + }); + } + + async function uncommitChanges(stackId: string, commitId: string, changes: TreeChange[]) { + const { replacedCommits } = await stackService.uncommitChanges({ + projectId, + stackId, + commitId, + changes: changesToDiffSpec(changes) + }); + const newCommitId = replacedCommits.find(([before]) => before === commitId)?.[1]; + const branchName = uiState.lane(stackId).selection.current?.branchName; + const selectedFiles = changes.map((change) => ({ ...selectionId, path: change.path })); + + idSelection.removeMany(selectedFiles); + + if (newCommitId && branchName) { + const previewOpen = uiState.lane(stackId).selection.current?.previewOpen ?? false; + uiState.lane(stackId).selection.set({ branchName, commitId: newCommitId, previewOpen }); + } + contextMenu.close(); + } + + async function triggerAutoCommit(changes: TreeChange[]) { + if (!canUseGBAI) { + chipToasts.error('GitButler AI is not configured or enabled for this project.'); + return; + } + + try { + await chipToasts.promise(autoCommit({ projectId, changes }), { + loading: 'Started auto commit', + success: 'Auto commit succeeded', + error: 'Auto commit failed' + }); + } catch (error) { + console.error('Auto commit failed:', error); + } + } + + async function triggerBranchChanges(changes: TreeChange[]) { + if (!canUseGBAI) { + chipToasts.error('GitButler AI is not configured or enabled for this project.'); + return; + } + + try { + await chipToasts.promise(branchChanges({ projectId, changes }), { + loading: 'Creating a branch and committing changes', + success: 'Branching changes succeeded', + error: 'Branching changes failed' + }); + } catch (error) { + console.error('Branching changes failed:', error); + } + } + + async function triggerAbsorbChanges(changes: TreeChange[]) { + if (!canUseGBAI) { + chipToasts.error('GitButler AI is not configured or enabled for this project.'); + return; + } + + try { + await chipToasts.promise(absorbChanges({ projectId, changes }), { + loading: 'Looking for the best place to absorb the changes', + success: 'Absorbing changes succeeded', + error: 'Absorbing changes failed' + }); + } catch (error) { + console.error('Absorbing changes failed:', error); + } + } + + async function split(changes: TreeChange[]) { + if (!stackId) { + chipToasts.error('No stack selected to split off changes.'); + return; + } + + if (selectionId.type !== 'branch') { + chipToasts.error('Please select a branch to split off changes.'); + return; + } + + const branchName = selectionId.branchName; + const fileNames = changes.map((change) => change.path); + + try { + await chipToasts.promise( + (async () => { + const newBranchName = await stackService.fetchNewBranchName(projectId); + + if (!newBranchName) { + throw new Error('Failed to generate a new branch name.'); + } + + await splitOffChanges({ + projectId, + sourceStackId: stackId, + sourceBranchName: branchName, + fileChangesToSplitOff: fileNames, + newBranchName: newBranchName + }); + })(), + { + loading: 'Splitting off changes', + success: 'Changes split off into a new branch', + error: 'Failed to split off changes' + } + ); + } catch (error) { + console.error('Failed to split off changes:', error); + } + } + + async function splitIntoDependentBranch(changes: TreeChange[]) { + if (!stackId) { + chipToasts.error('No stack selected to split off changes.'); + return; + } + + if (selectionId.type !== 'branch') { + chipToasts.error('Please select a branch to split off changes.'); + return; + } + + const branchName = selectionId.branchName; + const fileNames = changes.map((change) => change.path); + + try { + await chipToasts.promise( + (async () => { + const newBranchName = await stackService.fetchNewBranchName(projectId); + + if (!newBranchName) { + throw new Error('Failed to generate a new branch name.'); + } + + await splitBranchIntoDependentBranch({ + projectId, + sourceStackId: stackId, + sourceBranchName: branchName, + fileChangesToSplitOff: fileNames, + newBranchName: newBranchName + }); + })(), + { + loading: 'Splitting into dependent branch', + success: 'Changes split into a dependent branch', + error: 'Failed to split into dependent branch' + } + ); + } catch (error) { + console.error('Failed to split into dependent branch:', error); + } + } + + + + {#snippet children(item: unknown)} + {#if isFolderItem(item)} + {#if item.changes.length > 0 && !editMode} + + {@const changes = item.changes} + {#if isUncommitted} + { + confirmationModal?.show(item); + contextMenu.close(); + }} + /> + {/if} + {#if isUncommitted} + { + stackService.fetchNewBranchName(projectId).then((name) => { + stashBranchName = name || ''; + }); + stashConfirmationModal?.show(item); + contextMenu.close(); + }} + /> + {/if} + {#if selectionId.type === 'commit' && stackId && !editMode} + {@const commitId = selectionId.commitId} + uncommitChanges(stackId, commitId, changes)} + /> + {/if} + + {#if isBranchFiles && stackId && selectionBranchName} + {@const branchIsConflicted = stackService.isBranchConflicted( + projectId, + stackId, + selectionBranchName + )} + + {#snippet children(isConflicted)} + {#if isConflicted === false} + { + split(changes); + contextMenu.close(); + }} + /> + { + splitIntoDependentBranch(changes); + contextMenu.close(); + }} + /> + {/if} + {/snippet} + + {/if} + + {/if} + + + + {#snippet submenu({ close: closeSubmenu })} + + { + const project = await projectService.fetchProject(projectId); + const projectPath = project?.path; + if (projectPath) { + const absPath = await backend.joinPath(projectPath, item.path); + + await clipboardService.write(absPath, { + message: 'Absolute path copied', + errorMessage: 'Failed to copy absolute path' + }); + } + closeSubmenu(); + contextMenu.close(); + }} + /> + { + await clipboardService.write(item.path, { + message: 'Relative path copied', + errorMessage: 'Failed to copy relative path' + }); + closeSubmenu(); + contextMenu.close(); + }} + /> + + {/snippet} + + + + + { + const project = await projectService.fetchProject(projectId); + const projectPath = project?.path; + if (projectPath) { + const absPath = await backend.joinPath(projectPath, item.path); + await fileService.showFileInFolder(absPath); + } + contextMenu.close(); + }} + /> + + + {#if canUseGBAI && isUncommitted} + + + {#snippet submenu({ close: closeSubmenu })} + + { + closeSubmenu(); + contextMenu.close(); + triggerAutoCommit(item.changes); + }} + disabled={autoCommitting.current.isLoading} + /> + { + closeSubmenu(); + contextMenu.close(); + triggerBranchChanges(item.changes); + }} + disabled={branchingChanges.current.isLoading} + /> + { + closeSubmenu(); + contextMenu.close(); + triggerAbsorbChanges(item.changes); + }} + disabled={absorbingChanges.current.isLoading} + /> + + {/snippet} + + + {/if} + {:else} + +

Woops! Malformed data :(

+
+ {/if} + {/snippet} +
+ + isFolderItem(item) && confirmDiscard(item)} +> + {#snippet children(item)} + {#if isFolderItem(item)} +

+ Discard all changes in {item.path}? This will affect + {item.changes.length} file{item.changes.length === 1 ? '' : 's'}. +

+ {:else} +

Woops! Malformed data :(

+ {/if} + {/snippet} + {#snippet controls(close, item)} + + await confirmDiscard(item)}> + Confirm + + {/snippet} +
+ + isFolderItem(item) && confirmStashIntoBranch(item, stashBranchName)} +> +
+ + +
+

+ All changes in this folder will be moved to a new branch and removed from your current + workspace. To get these changes back later, switch to the new branch and uncommit the stash. +

+
+ +
+

+ 💡 This creates a new branch, commits your changes, then unapplies the branch. Future + versions will have simpler stash management. +

+
+
+ + {#snippet controls(close, item)} + + await confirmStashIntoBranch(item, stashBranchName)} + > + Stash into branch + + {/snippet} +
+ + diff --git a/apps/desktop/src/components/TreeListFolder.svelte b/apps/desktop/src/components/TreeListFolder.svelte index bd3114c4f8..990077f8b9 100644 --- a/apps/desktop/src/components/TreeListFolder.svelte +++ b/apps/desktop/src/components/TreeListFolder.svelte @@ -1,11 +1,15 @@ - handleCheck(e.currentTarget.checked)} - {onclick} - {ontoggle} -/> +
+ + + handleCheck(e.currentTarget.checked)} + {onclick} + {ontoggle} + oncontextmenu={onContextMenu} + /> +
diff --git a/packages/ui/src/lib/components/file/FolderListItem.svelte b/packages/ui/src/lib/components/file/FolderListItem.svelte index 12417494a9..82109bd808 100644 --- a/packages/ui/src/lib/components/file/FolderListItem.svelte +++ b/packages/ui/src/lib/components/file/FolderListItem.svelte @@ -19,6 +19,7 @@ ontoggle?: (expanded: boolean) => void; onclick?: (e: MouseEvent) => void; onkeydown?: (e: KeyboardEvent) => void; + oncontextmenu?: (e: MouseEvent) => void; testId?: string; } @@ -34,6 +35,7 @@ ontoggle, onclick, onkeydown, + oncontextmenu, testId }: Props = $props(); @@ -51,6 +53,13 @@ onclick?.(e); }} {onkeydown} + oncontextmenu={(e) => { + if (oncontextmenu) { + e.preventDefault(); + e.stopPropagation(); + oncontextmenu(e); + } + }} >
From ef41b9fc5246bbe322a37d73f891c55927a44dbb Mon Sep 17 00:00:00 2001 From: Casper Jeukendrup <48658420+cbjeukendrup@users.noreply.github.com> Date: Thu, 30 Oct 2025 03:50:08 +0100 Subject: [PATCH 2/5] Merge FileContextMenu and FolderContextMenu to avoid code duplication --- ....svelte => ChangedFilesContextMenu.svelte} | 198 ++++--- apps/desktop/src/components/EditMode.svelte | 6 +- .../src/components/FileListItemWrapper.svelte | 6 +- .../src/components/FolderContextMenu.svelte | 548 ------------------ .../src/components/TreeListFolder.svelte | 6 +- 5 files changed, 127 insertions(+), 637 deletions(-) rename apps/desktop/src/components/{FileContextMenu.svelte => ChangedFilesContextMenu.svelte} (80%) delete mode 100644 apps/desktop/src/components/FolderContextMenu.svelte diff --git a/apps/desktop/src/components/FileContextMenu.svelte b/apps/desktop/src/components/ChangedFilesContextMenu.svelte similarity index 80% rename from apps/desktop/src/components/FileContextMenu.svelte rename to apps/desktop/src/components/ChangedFilesContextMenu.svelte index a35e64976b..c84fbbd407 100644 --- a/apps/desktop/src/components/FileContextMenu.svelte +++ b/apps/desktop/src/components/ChangedFilesContextMenu.svelte @@ -42,11 +42,11 @@ editMode?: boolean; }; - type FileItem = { + type ChangedFilesItem = { changes: TreeChange[]; }; - function isFileItem(item: unknown): item is FileItem { + function isChangedFilesItem(item: unknown): item is ChangedFilesItem { return ( typeof item === 'object' && item !== null && @@ -56,6 +56,14 @@ ); } + type ChangedFolderItem = ChangedFilesItem & { + path: string; + }; + + function isChangedFolderItem(item: ChangedFilesItem): item is ChangedFolderItem { + return 'path' in item && typeof item.path === 'string'; + } + const { trigger, selectionId, stackId, projectId, editMode = false }: Props = $props(); const stackService = inject(STACK_SERVICE); const uiState = inject(UI_STATE); @@ -105,13 +113,26 @@ $aiGenEnabled && aiConfigurationValid && $experimentalFeaturesEnabled ); - function isDeleted(item: FileItem): boolean { + function isDeleted(item: ChangedFilesItem): boolean { + if (isChangedFolderItem(item)) { + return false; + } return item.changes.some((change) => { return change.status.type === 'Deletion'; }); } - async function confirmDiscard(item: FileItem) { + function getItemPath(item: ChangedFilesItem): string | null { + if (isChangedFolderItem(item)) { + return item.path; + } + if (item.changes.length === 1) { + return item.changes[0]!.path; + } + return null; + } + + async function confirmDiscard(item: ChangedFilesItem) { await stackService.discardChanges({ projectId, worktreeChanges: changesToDiffSpec(item.changes) @@ -127,7 +148,8 @@ let stashBranchName = $state(); const slugifiedRefName = $derived(stashBranchName && slugify(stashBranchName)); - async function confirmStashIntoBranch(item: FileItem, branchName: string | undefined) { + + async function confirmStashIntoBranch(item: ChangedFilesItem, branchName: string | undefined) { if (!branchName) { return; } @@ -141,7 +163,7 @@ stashConfirmationModal?.close(); } - export function open(e: MouseEvent, item: FileItem) { + export function open(e: MouseEvent, item: ChangedFilesItem) { contextMenu.open(e, item); aiService.validateGitButlerAPIConfiguration().then((value) => { aiConfigurationValid = value; @@ -309,8 +331,9 @@ {#snippet children(item: unknown)} - {#if isFileItem(item)} + {#if isChangedFilesItem(item)} {@const deletion = isDeleted(item)} + {@const itemPath = getItemPath(item)} {#if item.changes.length > 0 && !editMode} {@const changes = item.changes} @@ -378,7 +401,7 @@ {/if} - {#if item.changes.length === 1} + {#if itemPath} {#snippet submenu({ close: closeSubmenu })} @@ -389,7 +412,7 @@ const project = await projectService.fetchProject(projectId); const projectPath = project?.path; if (projectPath) { - const absPath = await backend.joinPath(projectPath, item.changes[0]!.path); + const absPath = await backend.joinPath(projectPath, itemPath); await clipboardService.write(absPath, { message: 'Absolute path copied', @@ -403,7 +426,7 @@ { - await clipboardService.write(item.changes[0]!.path, { + await clipboardService.write(itemPath, { message: 'Relative path copied', errorMessage: 'Failed to copy relative path' }); @@ -418,31 +441,33 @@ {/if} - { - try { - const project = await projectService.fetchProject(projectId); - const projectPath = project?.path; - if (projectPath) { - for (let change of item.changes) { - const path = getEditorUri({ - schemeId: $userSettings.defaultCodeEditor.schemeIdentifer, - path: [vscodePath(projectPath), change.path] - }); - urlService.openExternalUrl(path); + {#if !isChangedFolderItem(item)} + { + try { + const project = await projectService.fetchProject(projectId); + const projectPath = project?.path; + if (projectPath) { + for (let change of item.changes) { + const path = getEditorUri({ + schemeId: $userSettings.defaultCodeEditor.schemeIdentifer, + path: [vscodePath(projectPath), change.path] + }); + urlService.openExternalUrl(path); + } } + contextMenu.close(); + } catch { + chipToasts.error('Failed to open in editor'); + console.error('Failed to open in editor'); } - contextMenu.close(); - } catch { - chipToasts.error('Failed to open in editor'); - console.error('Failed to open in editor'); - } - }} - /> - {#if item.changes.length === 1} + }} + /> + {/if} + {#if itemPath} isFileItem(item) && confirmDiscard(item)} + onSubmit={(_, item) => isChangedFilesItem(item) && confirmDiscard(item)} > {#snippet children(item)} - {#if isFileItem(item)} - {@const changes = item.changes} - {#if changes.length < 10} + {#if isChangedFilesItem(item)} + {#if isChangedFolderItem(item)}

- Are you sure you want to discard the changes
to the following files: + Are you sure you want to discard all changes in + {item.path}?

-
    - {#each changes as change, i} - - {/each} -
{:else} -

- Discard the changes to all - {changes.length} files - ? -

+ {@const changes = item.changes} + {#if changes.length < 10} +

+ Are you sure you want to discard the changes
to the following files: +

+
    + {#each changes as change, i} + + {/each} +
+ {:else} +

+ Discard the changes to all + {changes.length} files + ? +

+ {/if} {/if} {:else}

Woops! Malformed data :(

@@ -556,34 +588,40 @@ type="info" title="Stash changes into a new branch" bind:this={stashConfirmationModal} - onSubmit={(_, item) => isFileItem(item) && confirmStashIntoBranch(item, stashBranchName)} + onSubmit={(_, item) => isChangedFilesItem(item) && confirmStashIntoBranch(item, stashBranchName)} > -
- - -
-

- Your selected changes will be moved to a new branch and removed from your current workspace. - To get these changes back later, switch to the new branch and uncommit the stash. -

-
+ {#snippet children(item)} +
+ + +
+

+ {#if isChangedFolderItem(item)} + All changes in this folder + {:else} + Your selected changes + {/if} + will be moved to a new branch and removed from your current workspace. To get these changes + back later, switch to the new branch and uncommit the stash. +

+
-
-

- 💡 This creates a new branch, commits your changes, then unapplies the branch. Future - versions will have simpler stash management. -

+
+

+ 💡 This creates a new branch, commits your changes, then unapplies the branch. Future + versions will have simpler stash management. +

+
-
- + {/snippet} {#snippet controls(close, item)} + import ChangedFilesContextMenu from '$components/ChangedFilesContextMenu.svelte'; import ScrollableContainer from '$components/ConfigurableScrollableContainer.svelte'; - import FileContextMenu from '$components/FileContextMenu.svelte'; import ReduxResult from '$components/ReduxResult.svelte'; import { conflictEntryHint, @@ -63,7 +63,7 @@ let commitQuery = $derived(stackService.commitDetails(projectId, editModeMetadata.commitOid)); let filesList = $state(undefined); - let contextMenu = $state | undefined>(undefined); + let contextMenu = $state | undefined>(undefined); let confirmSaveModal = $state | undefined>(undefined); interface FileEntry { @@ -319,7 +319,7 @@
- - - - {#snippet children(item: unknown)} - {#if isFolderItem(item)} - {#if item.changes.length > 0 && !editMode} - - {@const changes = item.changes} - {#if isUncommitted} - { - confirmationModal?.show(item); - contextMenu.close(); - }} - /> - {/if} - {#if isUncommitted} - { - stackService.fetchNewBranchName(projectId).then((name) => { - stashBranchName = name || ''; - }); - stashConfirmationModal?.show(item); - contextMenu.close(); - }} - /> - {/if} - {#if selectionId.type === 'commit' && stackId && !editMode} - {@const commitId = selectionId.commitId} - uncommitChanges(stackId, commitId, changes)} - /> - {/if} - - {#if isBranchFiles && stackId && selectionBranchName} - {@const branchIsConflicted = stackService.isBranchConflicted( - projectId, - stackId, - selectionBranchName - )} - - {#snippet children(isConflicted)} - {#if isConflicted === false} - { - split(changes); - contextMenu.close(); - }} - /> - { - splitIntoDependentBranch(changes); - contextMenu.close(); - }} - /> - {/if} - {/snippet} - - {/if} - - {/if} - - - - {#snippet submenu({ close: closeSubmenu })} - - { - const project = await projectService.fetchProject(projectId); - const projectPath = project?.path; - if (projectPath) { - const absPath = await backend.joinPath(projectPath, item.path); - - await clipboardService.write(absPath, { - message: 'Absolute path copied', - errorMessage: 'Failed to copy absolute path' - }); - } - closeSubmenu(); - contextMenu.close(); - }} - /> - { - await clipboardService.write(item.path, { - message: 'Relative path copied', - errorMessage: 'Failed to copy relative path' - }); - closeSubmenu(); - contextMenu.close(); - }} - /> - - {/snippet} - - - - - { - const project = await projectService.fetchProject(projectId); - const projectPath = project?.path; - if (projectPath) { - const absPath = await backend.joinPath(projectPath, item.path); - await fileService.showFileInFolder(absPath); - } - contextMenu.close(); - }} - /> - - - {#if canUseGBAI && isUncommitted} - - - {#snippet submenu({ close: closeSubmenu })} - - { - closeSubmenu(); - contextMenu.close(); - triggerAutoCommit(item.changes); - }} - disabled={autoCommitting.current.isLoading} - /> - { - closeSubmenu(); - contextMenu.close(); - triggerBranchChanges(item.changes); - }} - disabled={branchingChanges.current.isLoading} - /> - { - closeSubmenu(); - contextMenu.close(); - triggerAbsorbChanges(item.changes); - }} - disabled={absorbingChanges.current.isLoading} - /> - - {/snippet} - - - {/if} - {:else} - -

Woops! Malformed data :(

-
- {/if} - {/snippet} -
- - isFolderItem(item) && confirmDiscard(item)} -> - {#snippet children(item)} - {#if isFolderItem(item)} -

- Discard all changes in {item.path}? This will affect - {item.changes.length} file{item.changes.length === 1 ? '' : 's'}. -

- {:else} -

Woops! Malformed data :(

- {/if} - {/snippet} - {#snippet controls(close, item)} - - await confirmDiscard(item)}> - Confirm - - {/snippet} -
- - isFolderItem(item) && confirmStashIntoBranch(item, stashBranchName)} -> -
- - -
-

- All changes in this folder will be moved to a new branch and removed from your current - workspace. To get these changes back later, switch to the new branch and uncommit the stash. -

-
- -
-

- 💡 This creates a new branch, commits your changes, then unapplies the branch. Future - versions will have simpler stash management. -

-
-
- - {#snippet controls(close, item)} - - await confirmStashIntoBranch(item, stashBranchName)} - > - Stash into branch - - {/snippet} -
- - diff --git a/apps/desktop/src/components/TreeListFolder.svelte b/apps/desktop/src/components/TreeListFolder.svelte index 990077f8b9..9c9228f22c 100644 --- a/apps/desktop/src/components/TreeListFolder.svelte +++ b/apps/desktop/src/components/TreeListFolder.svelte @@ -1,5 +1,5 @@
- Date: Mon, 27 Oct 2025 05:29:26 +0100 Subject: [PATCH 3/5] Support dragging folder items in changed files tree Dragging a folder item is equivalent to selecting the file items contained in that folder and dragging those. --- apps/desktop/src/components/FileList.svelte | 1 + .../src/components/FileListItemWrapper.svelte | 5 +-- .../src/components/FileTreeNode.svelte | 5 +++ .../src/components/TreeListFolder.svelte | 43 ++++++++++++++++--- apps/desktop/src/lib/codegen/dropzone.ts | 6 +-- apps/desktop/src/lib/commits/dropHandler.ts | 13 ++++-- apps/desktop/src/lib/dragging/draggable.ts | 23 ++++++++-- apps/desktop/src/lib/dragging/draggables.ts | 25 ++++++++++- apps/desktop/src/lib/hunks/dropHandler.ts | 25 +++++++++-- apps/desktop/src/lib/stacks/dropHandler.ts | 8 +++- .../lib/components/file/FileListItem.svelte | 24 +++++------ .../lib/components/file/FolderListItem.svelte | 29 +++++++++++++ 12 files changed, 169 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/components/FileList.svelte b/apps/desktop/src/components/FileList.svelte index cef1ba2284..3e8a4e7eca 100644 --- a/apps/desktop/src/components/FileList.svelte +++ b/apps/desktop/src/components/FileList.svelte @@ -339,6 +339,7 @@ {stackId} {node} {showCheckboxes} + {draggableFiles} {changes} {fileTemplate} /> diff --git a/apps/desktop/src/components/FileListItemWrapper.svelte b/apps/desktop/src/components/FileListItemWrapper.svelte index 4da1026a00..220f2184ed 100644 --- a/apps/desktop/src/components/FileListItemWrapper.svelte +++ b/apps/desktop/src/components/FileListItemWrapper.svelte @@ -3,7 +3,7 @@ import ChangedFilesContextMenu from '$components/ChangedFilesContextMenu.svelte'; import { conflictEntryHint } from '$lib/conflictEntryPresence'; import { draggableChips } from '$lib/dragging/draggable'; - import { ChangeDropData } from '$lib/dragging/draggables'; + import { FileChangeDropData } from '$lib/dragging/draggables'; import { DROPZONE_REGISTRY } from '$lib/dragging/registry'; import { getFilename } from '$lib/files/utils'; import { type TreeChange } from '$lib/hunks/change'; @@ -132,9 +132,8 @@ use:draggableChips={{ label: getFilename(change.path), filePath: change.path, - data: new ChangeDropData(projectId, change, idSelection, selectionId, stackId || undefined), + data: new FileChangeDropData(projectId, change, idSelection, selectionId, stackId || undefined), viewportId: 'board-viewport', - selector: '.selected-draggable', disabled: draggableDisabled, chipType: 'file', dropzoneRegistry, diff --git a/apps/desktop/src/components/FileTreeNode.svelte b/apps/desktop/src/components/FileTreeNode.svelte index 34525da8e5..6e4d026fc4 100644 --- a/apps/desktop/src/components/FileTreeNode.svelte +++ b/apps/desktop/src/components/FileTreeNode.svelte @@ -14,6 +14,7 @@ node: TreeNode; isRoot?: boolean; showCheckboxes?: boolean; + draggableFiles?: boolean; changes: TreeChange[]; depth?: number; initiallyExpanded?: boolean; @@ -27,6 +28,7 @@ node, isRoot = false, showCheckboxes, + draggableFiles, changes, depth = 0, fileTemplate @@ -51,6 +53,7 @@ {depth} node={childNode} {showCheckboxes} + {draggableFiles} {changes} {fileTemplate} /> @@ -66,6 +69,7 @@ {depth} {isExpanded} showCheckbox={showCheckboxes} + draggable={draggableFiles} {node} ontoggle={handleToggle} /> @@ -79,6 +83,7 @@ depth={depth + 1} node={childNode} {showCheckboxes} + {draggableFiles} {changes} {fileTemplate} /> diff --git a/apps/desktop/src/components/TreeListFolder.svelte b/apps/desktop/src/components/TreeListFolder.svelte index 9c9228f22c..b2d2a8ddc1 100644 --- a/apps/desktop/src/components/TreeListFolder.svelte +++ b/apps/desktop/src/components/TreeListFolder.svelte @@ -1,9 +1,13 @@ -
+
handleCheck(e.currentTarget.checked)} {onclick} {ontoggle} diff --git a/apps/desktop/src/lib/codegen/dropzone.ts b/apps/desktop/src/lib/codegen/dropzone.ts index 5b14ab67cd..8b3bc7d67f 100644 --- a/apps/desktop/src/lib/codegen/dropzone.ts +++ b/apps/desktop/src/lib/codegen/dropzone.ts @@ -1,5 +1,5 @@ import { CommitDropData } from '$lib/commits/dropHandler'; -import { ChangeDropData, HunkDropDataV3 } from '$lib/dragging/draggables'; +import { FileChangeDropData, HunkDropDataV3 } from '$lib/dragging/draggables'; import type { PromptAttachment } from '$lib/codegen/types'; import type { DropzoneHandler } from '$lib/dragging/handler'; import type { AiRule } from '$lib/rules/rule'; @@ -70,12 +70,12 @@ export class CodegenFileDropHandler implements DropzoneHandler { accepts(data: unknown): boolean { return ( - data instanceof ChangeDropData && + data instanceof FileChangeDropData && (data.stackId === undefined || data.stackId === this.stackId) ); } - async ondrop(data: ChangeDropData): Promise { + async ondrop(data: FileChangeDropData): Promise { const changes = await data.treeChanges(); const commitId = data.selectionId.type === 'commit' ? data.selectionId.commitId : undefined; const attachments: PromptAttachment[] = changes.map((change) => ({ diff --git a/apps/desktop/src/lib/commits/dropHandler.ts b/apps/desktop/src/lib/commits/dropHandler.ts index a9c0f140c8..214d117b12 100644 --- a/apps/desktop/src/lib/commits/dropHandler.ts +++ b/apps/desktop/src/lib/commits/dropHandler.ts @@ -3,7 +3,12 @@ import { type MoveCommitIllegalAction } from '$lib/commits/commit'; import { changesToDiffSpec } from '$lib/commits/utils'; -import { ChangeDropData, HunkDropDataV3 } from '$lib/dragging/draggables'; +import { + FileChangeDropData, + FolderChangeDropData, + HunkDropDataV3, + type ChangeDropData +} from '$lib/dragging/draggables'; import { type HooksService } from '$lib/hooks/hooksService'; import { showToast } from '$lib/notifications/toasts'; import { untrack } from 'svelte'; @@ -90,7 +95,7 @@ export class AmendCommitWithChangeDzHandler implements DropzoneHandler { private readonly uiState: UiState ) {} accepts(data: unknown): boolean { - if (!(data instanceof ChangeDropData)) return false; + if (!(data instanceof FileChangeDropData || data instanceof FolderChangeDropData)) return false; if (this.commit.hasConflicts) return false; if (data.selectionId.type === 'branch') return false; if (data.selectionId.type === 'commit' && data.selectionId.commitId === this.commit.id) @@ -159,7 +164,7 @@ export class UncommitDzHandler implements DropzoneHandler { ) {} accepts(data: unknown): boolean { - if (data instanceof ChangeDropData) { + if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) { if (data.selectionId.type !== 'commit') return false; if (!data.selectionId.commitId) return false; if (!data.stackId) return false; @@ -175,7 +180,7 @@ export class UncommitDzHandler implements DropzoneHandler { } async ondrop(data: ChangeDropData | HunkDropDataV3) { - if (data instanceof ChangeDropData) { + if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) { switch (data.selectionId.type) { case 'commit': { const stackId = data.stackId; diff --git a/apps/desktop/src/lib/dragging/draggable.ts b/apps/desktop/src/lib/dragging/draggable.ts index 7080953d66..f9795be0eb 100644 --- a/apps/desktop/src/lib/dragging/draggable.ts +++ b/apps/desktop/src/lib/dragging/draggable.ts @@ -1,6 +1,6 @@ import { getColorFromCommitState } from '$components/lib'; import { type CommitStatusType } from '$lib/commits/commit'; -import { ChangeDropData, type DropData } from '$lib/dragging/draggables'; +import { FileChangeDropData, type DropData } from '$lib/dragging/draggables'; import { getFileIcon } from '@gitbutler/ui/components/file/getFileIcon'; import iconsJson from '@gitbutler/ui/data/icons.json'; import { pxToRem } from '@gitbutler/ui/utils/pxToRem'; @@ -11,10 +11,9 @@ import type { DragStateService } from '@gitbutler/ui/drag/dragStateService.svelt // Added to element being dragged (not the clone that follows the cursor). const DRAGGING_CLASS = 'dragging'; -type chipType = 'file' | 'hunk' | 'ai-session' | 'branch'; +type chipType = 'file' | 'folder' | 'hunk' | 'ai-session' | 'branch'; export type DraggableConfig = { - readonly selector?: string; readonly disabled?: boolean; readonly label?: string; readonly filePath?: string; @@ -86,7 +85,7 @@ function setupDragHandlers( endDragging = opts.dragStateService.startDragging(); } - if (opts.data instanceof ChangeDropData) { + if (opts.data instanceof FileChangeDropData) { selectedElements = []; for (const path of opts.data.changedPaths(opts.data.selectionId)) { // Path is sufficient as a key since we query the parent container. @@ -323,6 +322,20 @@ function createFileChip(label?: string, filePath?: string): HTMLDivElement { return el; } +function createFolderChip(label?: string): HTMLDivElement { + const el = createElement('div', ['dragchip-file-container']); + const icon = createSVGIcon(iconsJson['folder'], ['dragchip-file-icon']); + el.appendChild(icon); + el.appendChild( + createElement( + 'span', + ['text-12', 'text-semibold', 'dragchip-file-name'], + label || 'Empty folder' + ) + ); + return el; +} + function createHunkChip(label?: string): HTMLDivElement { const el = createElement('div', ['dragchip-hunk-container']); const deco = createElement('div', ['dragchip-hunk-decorator'], '〈/〉'); @@ -384,6 +397,8 @@ export function createChipsElement({ if (chipType === 'file') { chip.appendChild(createFileChip(label, filePath)); + } else if (chipType === 'folder') { + chip.appendChild(createFolderChip(label)); } else if (chipType === 'hunk') { chip.appendChild(createHunkChip(label)); } diff --git a/apps/desktop/src/lib/dragging/draggables.ts b/apps/desktop/src/lib/dragging/draggables.ts index d6d6e3ff8c..fc67bd3f51 100644 --- a/apps/desktop/src/lib/dragging/draggables.ts +++ b/apps/desktop/src/lib/dragging/draggables.ts @@ -17,7 +17,7 @@ export class HunkDropDataV3 { ) {} } -export class ChangeDropData { +export class FileChangeDropData { constructor( private projectId: string, readonly change: TreeChange, @@ -66,6 +66,29 @@ export class ChangeDropData { } } +export class FolderChangeDropData { + constructor( + readonly folderPath: string, + private getTreeChanges: () => TreeChange[], + readonly selectionId: SelectionId, + readonly stackId?: string + ) {} + + async treeChanges(): Promise { + return this.getTreeChanges(); + } + + assignments(): undefined { + return undefined; + } + + get isCommitted(): boolean { + return this.selectionId.type === 'commit' || this.selectionId.type === 'branch'; + } +} + +export type ChangeDropData = FileChangeDropData | FolderChangeDropData; + export type DropData = | CommitDropData | ChangeDropData diff --git a/apps/desktop/src/lib/hunks/dropHandler.ts b/apps/desktop/src/lib/hunks/dropHandler.ts index c0e0e0439d..2c2e85d3e2 100644 --- a/apps/desktop/src/lib/hunks/dropHandler.ts +++ b/apps/desktop/src/lib/hunks/dropHandler.ts @@ -1,4 +1,9 @@ -import { ChangeDropData, HunkDropDataV3 } from '$lib/dragging/draggables'; +import { + FileChangeDropData, + FolderChangeDropData, + HunkDropDataV3, + type ChangeDropData +} from '$lib/dragging/draggables'; import { type DiffService } from '$lib/hunks/diffService.svelte'; import type { DropzoneHandler } from '$lib/dragging/handler'; import type { FileSelectionManager } from '$lib/selection/fileSelectionManager.svelte'; @@ -14,7 +19,7 @@ export class AssignmentDropHandler implements DropzoneHandler { ) {} accepts(data: unknown) { - if (data instanceof ChangeDropData) { + if (data instanceof FileChangeDropData || data instanceof FolderChangeDropData) { if (data.isCommitted) return false; if (data.stackId === this.stackId) return false; return true; @@ -30,7 +35,7 @@ export class AssignmentDropHandler implements DropzoneHandler { async ondrop(data: ChangeDropData | HunkDropDataV3) { if (data.stackId === this.stackId) return; - if (data instanceof ChangeDropData) { + if (data instanceof FileChangeDropData) { // A whole file. const changes = await data.treeChanges(); const assignments = changes @@ -43,6 +48,20 @@ export class AssignmentDropHandler implements DropzoneHandler { // If files are coming from the uncommitted changes this.idSelection.remove(data.change.path, data.selectionId); + } else if (data instanceof FolderChangeDropData) { + // A whole folder. + const changes = await data.treeChanges(); + const assignments = changes + .flatMap((c) => this.uncommittedService.getAssignmentsByPath(data.stackId || null, c.path)) + .map((h) => ({ ...h, stackId: this.stackId || null })); + await this.diffService.assignHunk({ + projectId: this.projectId, + assignments + }); + + for (const change of changes) { + this.idSelection.remove(change.path, data.selectionId); + } } else { const assignment = this.uncommittedService.getAssignmentByHeader( data.stackId, diff --git a/apps/desktop/src/lib/stacks/dropHandler.ts b/apps/desktop/src/lib/stacks/dropHandler.ts index eac7eb4682..d065c38d23 100644 --- a/apps/desktop/src/lib/stacks/dropHandler.ts +++ b/apps/desktop/src/lib/stacks/dropHandler.ts @@ -1,6 +1,10 @@ import { BranchDropData } from '$lib/branches/dropHandler'; import { changesToDiffSpec } from '$lib/commits/utils'; -import { ChangeDropData } from '$lib/dragging/draggables'; +import { + FileChangeDropData, + FolderChangeDropData, + type ChangeDropData +} from '$lib/dragging/draggables'; import { unstackPRs, updateStackPrs } from '$lib/forge/shared/prFooter'; import StackMacros from '$lib/stacks/macros'; import { handleMoveBranchResult } from '$lib/stacks/stack'; @@ -30,7 +34,7 @@ export class OutsideLaneDzHandler implements DropzoneHandler { } private acceptsChangeDropData(data: unknown): data is ChangeDropData { - if (!(data instanceof ChangeDropData)) return false; + if (!(data instanceof FileChangeDropData || data instanceof FolderChangeDropData)) return false; if (data.selectionId.type === 'commit' && data.stackId === undefined) return false; return true; } diff --git a/packages/ui/src/lib/components/file/FileListItem.svelte b/packages/ui/src/lib/components/file/FileListItem.svelte index 22f195aa85..b5ec2df59d 100644 --- a/packages/ui/src/lib/components/file/FileListItem.svelte +++ b/packages/ui/src/lib/components/file/FileListItem.svelte @@ -225,6 +225,18 @@ border-bottom: 1px solid var(--clr-border-3); } + .draggable-handle { + display: flex; + position: absolute; + left: 0; + align-items: center; + justify-content: center; + height: 24px; + color: var(--clr-text-3); + opacity: 0; + transition: opacity var(--transition-fast); + } + &.draggable { &:hover { & .draggable-handle { @@ -245,18 +257,6 @@ gap: 6px; } - .draggable-handle { - display: flex; - position: absolute; - left: 0; - align-items: center; - justify-content: center; - height: 24px; - color: var(--clr-text-3); - opacity: 0; - transition: opacity var(--transition-fast); - } - .file-list-item__details { display: flex; flex-grow: 1; diff --git a/packages/ui/src/lib/components/file/FolderListItem.svelte b/packages/ui/src/lib/components/file/FolderListItem.svelte index 82109bd808..e190c2dc70 100644 --- a/packages/ui/src/lib/components/file/FolderListItem.svelte +++ b/packages/ui/src/lib/components/file/FolderListItem.svelte @@ -11,6 +11,7 @@ isExpanded?: boolean; depth?: number; transparent?: boolean; + draggable?: boolean; oncheck?: ( e: Event & { currentTarget: EventTarget & HTMLInputElement; @@ -31,6 +32,7 @@ isExpanded = true, depth, transparent, + draggable = false, oncheck, ontoggle, onclick, @@ -48,6 +50,7 @@ role="presentation" tabindex="-1" class:transparent + class:draggable onclick={(e) => { e.stopPropagation(); onclick?.(e); @@ -61,6 +64,12 @@ } }} > + {#if draggable && !showCheckbox} +
+ +
+ {/if} +
@@ -115,6 +124,26 @@ &.transparent { background-color: transparent; } + + .draggable-handle { + display: flex; + position: absolute; + left: 0; + align-items: center; + justify-content: center; + height: 24px; + color: var(--clr-text-3); + opacity: 0; + transition: opacity var(--transition-fast); + } + + &.draggable { + &:hover { + & .draggable-handle { + opacity: 1; + } + } + } } .folder-list-item__indicators { From 57f5eaedacb9cec4e1e6fe36255353981912c6a9 Mon Sep 17 00:00:00 2001 From: Pavel Laptev Date: Tue, 4 Nov 2025 15:11:38 +0100 Subject: [PATCH 4/5] Set file icon color in draggable styles --- apps/desktop/src/styles/draggable.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/styles/draggable.css b/apps/desktop/src/styles/draggable.css index 828e31545c..9d918b9910 100644 --- a/apps/desktop/src/styles/draggable.css +++ b/apps/desktop/src/styles/draggable.css @@ -86,6 +86,7 @@ .dragchip-file-icon { width: 16px; height: 16px; + color: var(--clr-text-2); } /* HUNK DRAG */ From bddee44140afd4f29325a2b2013a3c48402b37cc Mon Sep 17 00:00:00 2001 From: Pavel Laptev Date: Tue, 4 Nov 2025 15:18:40 +0100 Subject: [PATCH 5/5] Truncate file/folder chip names and improve styling Adds 'truncate' class to file and folder chip names to prevent overflow. Updates CSS to set overflow: hidden on chip container and prevent icon shrinking for better layout consistency. --- apps/desktop/src/lib/dragging/draggable.ts | 8 ++++++-- apps/desktop/src/styles/draggable.css | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/dragging/draggable.ts b/apps/desktop/src/lib/dragging/draggable.ts index f9795be0eb..1afc2070ac 100644 --- a/apps/desktop/src/lib/dragging/draggable.ts +++ b/apps/desktop/src/lib/dragging/draggable.ts @@ -317,7 +317,11 @@ function createFileChip(label?: string, filePath?: string): HTMLDivElement { el.appendChild(createElement('img', ['dragchip-file-icon'], undefined, icon)); } el.appendChild( - createElement('span', ['text-12', 'text-semibold', 'dragchip-file-name'], label || 'Empty file') + createElement( + 'span', + ['text-12', 'text-semibold', 'truncate', 'dragchip-file-name'], + label || 'Empty file' + ) ); return el; } @@ -329,7 +333,7 @@ function createFolderChip(label?: string): HTMLDivElement { el.appendChild( createElement( 'span', - ['text-12', 'text-semibold', 'dragchip-file-name'], + ['text-12', 'text-semibold', 'truncate', 'dragchip-file-name'], label || 'Empty folder' ) ); diff --git a/apps/desktop/src/styles/draggable.css b/apps/desktop/src/styles/draggable.css index 9d918b9910..95f4f2cc7f 100644 --- a/apps/desktop/src/styles/draggable.css +++ b/apps/desktop/src/styles/draggable.css @@ -76,6 +76,7 @@ position: relative; align-items: center; padding: 8px; + overflow: hidden; gap: 6px; } @@ -84,6 +85,7 @@ } .dragchip-file-icon { + flex-shrink: 0; width: 16px; height: 16px; color: var(--clr-text-2);