Repository: PostHog/posthog
Pull request #30638: feat(project-tree): apply changes directly + undo
Author: login: mariusandra
Created: 2025-04-01T13:06:21Z | Merged: 2025-04-01T16:13:27Z

## Problem

The "save" and "cancel" buttons to apply changes in the project file tree are unintuitive.

## Changes

![2025-04-01 15 03 23](https://github.com/user-attachments/assets/5ea56dd3-e1a9-44b6-a863-2c7cef41fb37)

- Applies changes automatically
- Adds "undo" for move and create operations
- Adds confirmation dialogs (still ugly dialogs) if moving > 50 files or deleting a folder with > 0 files
- Deletes don't really work yet, as the files will reappear as "unfiled"

## How did you test this code?

Clicked around locally - see screencast. Added a test for the python part.


Diff:
diff --git a/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
index 033214534f3d..81849a372b27 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx
@@ -14,7 +14,7 @@ import { projectTreeLogic } from './projectTreeLogic'
 import { joinPath, splitPath } from './utils'
 
 export function ProjectTree(): JSX.Element {
-    const { treeData, lastViewedId, viableItems, pendingActions, expandedFolders, expandedSearchFolders, searchTerm } =
+    const { treeData, lastViewedId, viableItems, expandedFolders, expandedSearchFolders, searchTerm } =
         useValues(projectTreeLogic)
 
     const {
@@ -27,8 +27,6 @@ export function ProjectTree(): JSX.Element {
         setExpandedFolders,
         setExpandedSearchFolders,
         loadFolder,
-        applyPendingActions,
-        cancelPendingActions,
     } = useActions(projectTreeLogic)
 
     const { showLayoutPanel, setPanelTreeRef, clearActivePanelIdentifier } = useActions(panelLayoutLogic)
@@ -50,24 +48,11 @@ export function ProjectTree(): JSX.Element {
             searchPlaceholder="Search your project"
             panelActions={
                 <>
-                    {pendingActions.length > 0 ? (
-                        <div className="flex gap-1">
-                            <Button.Root onClick={cancelPendingActions} size="sm" intent="outline">
-                                <Button.Label size="sm">Cancel</Button.Label>
-                            </Button.Root>
-                            <Button.Root onClick={applyPendingActions} size="sm" intent="outline">
-                                <Button.Label size="sm">Save</Button.Label>
-                            </Button.Root>
-                        </div>
-                    ) : (
-                        <>
-                            <Button.Root onClick={() => createFolder('')}>
-                                <Button.Icon>
-                                    <IconFolderPlus className="text-tertiary" />
-                                </Button.Icon>
-                            </Button.Root>
-                        </>
-                    )}
+                    <Button.Root onClick={() => createFolder('')}>
+                        <Button.Icon>
+                            <IconFolderPlus className="text-tertiary" />
+                        </Button.Icon>
+                    </Button.Root>
                 </>
             }
         >
diff --git a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
index e1cea073fce8..29d1f815a27a 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx
@@ -22,6 +22,8 @@ import { FolderState, ProjectTreeAction } from './types'
 import { convertFileSystemEntryToTreeDataItem, findInProjectTree, joinPath, splitPath } from './utils'
 
 const PAGINATION_LIMIT = 100
+const MOVE_ALERT_LIMIT = 50
+const DELETE_ALERT_LIMIT = 0
 
 export const projectTreeLogic = kea<projectTreeLogicType>([
     path(['layout', 'navigation-3000', 'components', 'projectTreeLogic']),
@@ -46,8 +48,6 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
         movedItem: (item: FileSystemEntry, oldPath: string, newPath: string) => ({ item, oldPath, newPath }),
         queueAction: (action: ProjectTreeAction) => ({ action }),
         removeQueuedAction: (action: ProjectTreeAction) => ({ action }),
-        applyPendingActions: true,
-        cancelPendingActions: true,
         createSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }),
         updateSavedItem: (savedItem: FileSystemEntry, oldPath: string) => ({ savedItem, oldPath }),
         deleteSavedItem: (savedItem: FileSystemEntry) => ({ savedItem }),
@@ -121,32 +121,85 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
         pendingLoader: [
             false,
             {
-                applyPendingActions: async () => {
-                    for (const action of values.pendingActions) {
-                        if (action.type === 'move' && action.newPath) {
-                            // TODO: move all in folder
-                            if (!action.item.id) {
-                                const response = await api.fileSystem.create({ ...action.item, path: action.newPath })
-                                actions.createSavedItem(response)
-                            } else {
-                                await api.fileSystem.move(action.item.id, action.newPath)
-                                actions.movedItem(action.item, action.item.path, action.newPath)
+                queueAction: async ({ action }) => {
+                    if (action.type === 'prepare-move' && action.newPath) {
+                        try {
+                            const response = await api.fileSystem.count(action.item.id)
+                            if (response && response.count > MOVE_ALERT_LIMIT) {
+                                const confirmMessage = `You're about to move ${response.count} items. Are you sure?`
+                                if (!confirm(confirmMessage)) {
+                                    actions.removeQueuedAction(action)
+                                    return false
+                                }
                             }
-                        } else if (action.type === 'create') {
+                            actions.queueAction({ ...action, type: 'move' })
+                        } catch (error) {
+                            console.error('Error moving item:', error)
+                            lemonToast.error(`Error moving item: ${error}`)
+                        }
+                    }
+                    if (action.type === 'move' && action.newPath) {
+                        try {
+                            const oldPath = action.item.path
+                            const newPath = action.newPath
+                            await api.fileSystem.move(action.item.id, newPath)
+                            actions.movedItem(action.item, oldPath, newPath)
+                            lemonToast.success('Item moved successfully', {
+                                button: {
+                                    label: 'Undo',
+                                    dataAttr: 'undo-project-tree-move',
+                                    action: () => {
+                                        actions.moveItem(newPath, oldPath)
+                                    },
+                                },
+                            })
+                        } catch (error) {
+                            console.error('Error moving item:', error)
+                            lemonToast.error(`Error moving item: ${error}`)
+                        }
+                    } else if (action.type === 'create') {
+                        try {
                             const response = await api.fileSystem.create(action.item)
                             actions.createSavedItem(response)
-                        } else if (action.type === 'delete' && action.item.id) {
+                            lemonToast.success('Folder created successfully', {
+                                button: {
+                                    label: 'Undo',
+                                    dataAttr: 'undo-project-tree-move',
+                                    action: () => {
+                                        actions.deleteItem(response)
+                                    },
+                                },
+                            })
+                        } catch (error) {
+                            console.error('Error creating folder:', error)
+                            lemonToast.error(`Error creating folder: ${error}`)
+                        }
+                    } else if (action.type === 'prepare-delete' && action.item.id) {
+                        try {
+                            const response = await api.fileSystem.count(action.item.id)
+                            if (response && response.count > DELETE_ALERT_LIMIT) {
+                                const confirmMessage = `You're about to delete ${response.count} items. This can't be undone. Are you sure?`
+                                if (!confirm(confirmMessage)) {
+                                    actions.removeQueuedAction(action)
+                                    return false
+                                }
+                            }
+                            actions.queueAction({ ...action, type: 'delete' })
+                        } catch (error) {
+                            console.error('Error deleting item:', error)
+                            lemonToast.error(`Error deleting item: ${error}`)
+                        }
+                    } else if (action.type === 'delete' && action.item.id) {
+                        try {
                             await api.fileSystem.delete(action.item.id)
                             actions.deleteSavedItem(action.item)
+                            lemonToast.success('Item deleted successfully')
+                        } catch (error) {
+                            console.error('Error deleting item:', error)
+                            lemonToast.error(`Error deleting item: ${error}`)
                         }
-                        actions.removeQueuedAction(action)
-                    }
-                    return true
-                },
-                cancelPendingActions: async () => {
-                    for (const action of values.pendingActions) {
-                        actions.removeQueuedAction(action)
                     }
+                    actions.removeQueuedAction(action)
                     return true
                 },
             },
@@ -237,7 +290,6 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
             {
                 queueAction: (state, { action }) => [...state, action],
                 removeQueuedAction: (state, { action }) => state.filter((a) => a !== action),
-                cancelPendingActions: () => [],
             },
         ],
         expandedFolders: [
@@ -321,7 +373,7 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                                                 }
                                                 itemsByPath[newPath] = [
                                                     ...itemsByPath[newPath],
-                                                    { ...loopItem, path: newPath },
+                                                    { ...loopItem, path: newPath, _loading: true },
                                                 ]
                                             }
                                             delete itemsByPath[path]
@@ -331,7 +383,7 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                                 if (!itemTarget) {
                                     itemsByPath[action.newPath] = [
                                         ...(itemsByPath[action.newPath] ?? []),
-                                        { ...item, path: action.newPath },
+                                        { ...item, path: action.newPath, _loading: true },
                                     ]
                                 }
                                 delete itemsByPath[action.path]
@@ -339,7 +391,7 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                                 if (!itemsByPath[action.newPath]) {
                                     itemsByPath[action.newPath] = [
                                         ...(itemsByPath[action.newPath] ?? []),
-                                        { ...item, path: action.newPath },
+                                        { ...item, path: action.newPath, _loading: true },
                                     ]
                                     delete itemsByPath[action.path]
                                 } else {
@@ -351,13 +403,13 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                         if (!itemsByPath[action.newPath]) {
                             itemsByPath[action.newPath] = [
                                 ...(itemsByPath[action.newPath] ?? []),
-                                { ...action.item, path: action.newPath },
+                                { ...action.item, path: action.newPath, _loading: true },
                             ]
                         } else {
                             console.error("Item already exists, can't create", action.item)
                         }
-                    } else if (action.type === 'delete' && action.path) {
-                        delete itemsByPath[action.path]
+                    } else if (action.path) {
+                        itemsByPath[action.path] = itemsByPath[action.path].map((i) => ({ ...i, loading: true }))
                     }
                 }
                 return Object.values(itemsByPath).flatMap((a) => a)
@@ -369,7 +421,7 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
             (pendingActions) => {
                 const unappliedPaths: Record<string, boolean> = {}
                 for (const action of pendingActions) {
-                    if (action.type === 'move-create' || action.type === 'move' || action.type === 'create') {
+                    if (action.type === 'move' || action.type === 'create') {
                         if (action.newPath) {
                             unappliedPaths[action.newPath] = true
                             const split = splitPath(action.newPath)
@@ -545,8 +597,12 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
             }
             const item = values.viableItems.find((item) => item.path === oldPath)
             if (item && item.path === oldPath) {
+                if (!item.id) {
+                    lemonToast.error("Sorry, can't move an unsaved item (no id)")
+                    return
+                }
                 actions.queueAction({
-                    type: 'move',
+                    type: item.type === 'folder' ? 'prepare-move' : 'move',
                     item,
                     path: item.path,
                     newPath: newPath + item.path.slice(oldPath.length),
@@ -554,7 +610,7 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
             }
         },
         deleteItem: async ({ item }) => {
-            actions.queueAction({ type: 'delete', item, path: item.path })
+            actions.queueAction({ type: item.type === 'folder' ? 'prepare-delete' : 'delete', item, path: item.path })
         },
         addFolder: ({ folder }) => {
             if (values.viableItems.find((item) => item.path === folder)) {
@@ -587,12 +643,6 @@ export const projectTreeLogic = kea<projectTreeLogicType>([
                 }
             }
         },
-        cancelPendingActions: () => {
-            // Clear all pending actions without applying them
-            for (const action of values.pendingActions) {
-                actions.removeQueuedAction(action)
-            }
-        },
         rename: ({ path }) => {
             const splits = splitPath(path)
             if (splits.length > 0) {
diff --git a/frontend/src/layout/panel-layout/ProjectTree/types.ts b/frontend/src/layout/panel-layout/ProjectTree/types.ts
index 657e940b08c6..d64e3e119dab 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/types.ts
+++ b/frontend/src/layout/panel-layout/ProjectTree/types.ts
@@ -1,7 +1,7 @@
 import { FileSystemEntry } from '~/queries/schema/schema-general'
 
 export interface ProjectTreeAction {
-    type: 'move' | 'move-create' | 'create' | 'delete'
+    type: 'prepare-move' | 'move' | 'create' | 'prepare-delete' | 'delete'
     item: FileSystemEntry
     path: string
     newPath?: string
diff --git a/frontend/src/layout/panel-layout/ProjectTree/utils.tsx b/frontend/src/layout/panel-layout/ProjectTree/utils.tsx
index bb82b96cc287..3069f630ab53 100644
--- a/frontend/src/layout/panel-layout/ProjectTree/utils.tsx
+++ b/frontend/src/layout/panel-layout/ProjectTree/utils.tsx
@@ -67,7 +67,7 @@ export function convertFileSystemEntryToTreeDataItem(
             id: `${root}/${item.type === 'folder' ? item.path : item.id || item.path}`,
             name: itemName,
             displayName: <SearchHighlightMultiple string={itemName} substring={searchTerm} />,
-            icon: ('icon' in item && item.icon) || iconForType(item.type),
+            icon: item._loading ? <Spinner /> : ('icon' in item && item.icon) || iconForType(item.type),
             record: item,
             onClick: () => {
                 if (item.href) {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 6a16f58c320a..4ffa7dbab677 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -17,6 +17,7 @@ import {
     DatabaseSerializedFieldType,
     ErrorTrackingIssue,
     ErrorTrackingRelationalIssue,
+    FileSystemCount,
     FileSystemEntry,
     HogCompileResponse,
     HogQLVariable,
@@ -388,6 +389,9 @@ class ApiRequest {
     public fileSystemMove(id: NonNullable<FileSystemEntry['id']>, projectId?: ProjectType['id']): ApiRequest {
         return this.fileSystem(projectId).addPathComponent(id).addPathComponent('move')
     }
+    public fileSystemCount(id: NonNullable<FileSystemEntry['id']>, projectId?: ProjectType['id']): ApiRequest {
+        return this.fileSystem(projectId).addPathComponent(id).addPathComponent('count')
+    }
 
     // # Plugins
     public plugins(orgId?: OrganizationType['id']): ApiRequest {
@@ -1267,6 +1271,9 @@ const api = {
         async move(id: NonNullable<FileSystemEntry['id']>, newPath: string): Promise<FileSystemEntry> {
             return await new ApiRequest().fileSystemMove(id).create({ data: { new_path: newPath } })
         },
+        async count(id: NonNullable<FileSystemEntry['id']>): Promise<FileSystemCount> {
+            return await new ApiRequest().fileSystemCount(id).create()
+        },
     },
 
     organizationFeatureFlags: {
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index f9b43a3edce4..d790bb68b893 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -8493,9 +8493,23 @@
             "required": ["key", "operator", "type"],
             "type": "object"
         },
+        "FileSystemCount": {
+            "additionalProperties": false,
+            "properties": {
+                "count": {
+                    "type": "number"
+                }
+            },
+            "required": ["count"],
+            "type": "object"
+        },
         "FileSystemEntry": {
             "additionalProperties": false,
             "properties": {
+                "_loading": {
+                    "description": "Used to indicate pending actions, frontend only",
+                    "type": "boolean"
+                },
                 "created_at": {
                     "description": "Timestamp when file was added. Used to check persistence",
                     "type": "string"
@@ -8525,12 +8539,16 @@
                     "type": "string"
                 }
             },
-            "required": ["path"],
+            "required": ["id", "path"],
             "type": "object"
         },
         "FileSystemImport": {
             "additionalProperties": false,
             "properties": {
+                "_loading": {
+                    "description": "Used to indicate pending actions, frontend only",
+                    "type": "boolean"
+                },
                 "created_at": {
                     "description": "Timestamp when file was added. Used to check persistence",
                     "type": "string"
@@ -8555,7 +8573,6 @@
                 },
                 "icon": {},
                 "id": {
-                    "description": "Unique UUID for tree entry",
                     "type": "string"
                 },
                 "meta": {
diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts
index 3e6e31797666..2034de4d4170 100644
--- a/frontend/src/queries/schema/schema-general.ts
+++ b/frontend/src/queries/schema/schema-general.ts
@@ -1838,9 +1838,13 @@ export interface ErrorTrackingQueryResponse extends AnalyticsQueryResponseBase<E
 }
 export type CachedErrorTrackingQueryResponse = CachedQueryResponse<ErrorTrackingQueryResponse>
 
+export interface FileSystemCount {
+    count: number
+}
+
 export interface FileSystemEntry {
     /** Unique UUID for tree entry */
-    id?: string
+    id: string
     /** Object's name and folder */
     path: string
     /** Type of object, used for icon, e.g. feature_flag, insight, etc */
@@ -1853,10 +1857,13 @@ export interface FileSystemEntry {
     meta?: Record<string, any>
     /** Timestamp when file was added. Used to check persistence */
     created_at?: string
+    /** Used to indicate pending actions, frontend only */
+    _loading?: boolean
 }
 
-export interface FileSystemImport extends Omit<FileSystemEntry, 'href'> {
+export interface FileSystemImport extends Omit<FileSystemEntry, 'href' | 'id'> {
     icon?: any // Setting as "any" to keep Python schema.py in check
+    id?: string
     flag?: string
     href: (ref?: string) => string
 }
diff --git a/posthog/api/file_system.py b/posthog/api/file_system.py
index 5371c0f88314..c4a1fb1717b8 100644
--- a/posthog/api/file_system.py
+++ b/posthog/api/file_system.py
@@ -196,6 +196,15 @@ def move(self, request: Request, *args: Any, **kwargs: Any) -> Response:
             status=status.HTTP_200_OK,
         )
 
+    @action(methods=["POST"], detail=True)
+    def count(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+        """Get count of all files in a folder."""
+        instance = self.get_object()
+        if instance.type != "folder":
+            return Response({"detail": "Count can only be called on folders"}, status=status.HTTP_400_BAD_REQUEST)
+        count = FileSystem.objects.filter(team=self.team, path__startswith=f"{instance.path}/").count()
+        return Response({"count": count}, status=status.HTTP_200_OK)
+
 
 def retroactively_fix_folders_and_depth(team: Team, user: User) -> None:
     """
diff --git a/posthog/api/test/test_file_system.py b/posthog/api/test/test_file_system.py
index 3c58a2b61f36..ba3c201e7580 100644
--- a/posthog/api/test/test_file_system.py
+++ b/posthog/api/test/test_file_system.py
@@ -478,6 +478,20 @@ def test_move_files_and_folders(self):
         file2.refresh_from_db()
         self.assertEqual(file2.path, "NewFolder/File2")
 
+    def test_count_of_files(self):
+        """
+        Moving a folder should update all child paths correctly.
+        """
+        # Create a folder and some files inside it
+        folder = FileSystem.objects.create(team=self.team, path="OldFolder", type="folder", created_by=self.user)
+        FileSystem.objects.create(team=self.team, path="OldFolder/File1", type="doc", created_by=self.user)
+        FileSystem.objects.create(team=self.team, path="OldFolder/File2", type="doc", created_by=self.user)
+
+        # Move the folder
+        response = self.client.post(f"/api/projects/{self.team.id}/file_system/{folder.pk}/count")
+        self.assertEqual(response.status_code, status.HTTP_200_OK, response.json())
+        self.assertEqual(response.json()["count"], 2)
+
     def test_list_by_type_filter(self):
         """
         Ensure that the list endpoint filters results by the 'type' query parameter.
diff --git a/posthog/schema.py b/posthog/schema.py
index 0022abb2d321..8eb04a590a20 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -1038,15 +1038,25 @@ class FailureMessage(BaseModel):
     type: Literal["ai/failure"] = "ai/failure"
 
 
+class FileSystemCount(BaseModel):
+    model_config = ConfigDict(
+        extra="forbid",
+    )
+    count: float
+
+
 class FileSystemEntry(BaseModel):
     model_config = ConfigDict(
         extra="forbid",
     )
+    field_loading: Optional[bool] = Field(
+        default=None, alias="_loading", description="Used to indicate pending actions, frontend only"
+    )
     created_at: Optional[str] = Field(
         default=None, description="Timestamp when file was added. Used to check persistence"
     )
     href: Optional[str] = Field(default=None, description="Object's URL")
-    id: Optional[str] = Field(default=None, description="Unique UUID for tree entry")
+    id: str = Field(..., description="Unique UUID for tree entry")
     meta: Optional[dict[str, Any]] = Field(default=None, description="Metadata")
     path: str = Field(..., description="Object's name and folder")
     ref: Optional[str] = Field(default=None, description="Object's ID or other unique reference")
@@ -1070,13 +1080,16 @@ class FileSystemImport(BaseModel):
     model_config = ConfigDict(
         extra="forbid",
     )
+    field_loading: Optional[bool] = Field(
+        default=None, alias="_loading", description="Used to indicate pending actions, frontend only"
+    )
     created_at: Optional[str] = Field(
         default=None, description="Timestamp when file was added. Used to check persistence"
     )
     flag: Optional[str] = None
     href: Href
     icon: Optional[Any] = None
-    id: Optional[str] = Field(default=None, description="Unique UUID for tree entry")
+    id: Optional[str] = None
     meta: Optional[dict[str, Any]] = Field(default=None, description="Metadata")
     path: str = Field(..., description="Object's name and folder")
     ref: Optional[str] = Field(default=None, description="Object's ID or other unique reference")


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

Overall, this update shifts the project tree to auto-apply pending changes with undo support and confirmation dialogs for bulk operations, streamlining file management.  
- Updated frontend/src/layout/panel-layout/ProjectTree/projectTreeLogic.tsx to remove manual save/cancel and apply queued actions immediately, using confirm dialogs for large moves/deletes.  
- Simplified frontend/src/layout/panel-layout/ProjectTree/ProjectTree.tsx by removing pending actions UI and integrating undo via toast messages.  
- Adjusted types in frontend/src/layout/panel-layout/ProjectTree/types.ts and schema-general.ts to support required ids and loading flags.  
- Added a new count endpoint in posthog/api/file_system.py and corresponding API method in frontend/src/lib/api.ts to support bulk operation confirmations.

<sub>8 file(s) reviewed, 2 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_30638)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:08:09Z
user: login: adamleithp
body: APPROVED
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:15:43Z

Review comments:
diff_hunk: @@ -196,6 +196,13 @@ def move(self, request: Request, *args: Any, **kwargs: Any) -> Response:
             status=status.HTTP_200_OK,
         )
 
+    @action(methods=["POST"], detail=True)
+    def count(self, request: Request, *args: Any, **kwargs: Any) -> Response:
+        """Get count of all files in a folder."""
+        instance = self.get_object()
+        count = FileSystem.objects.filter(team=self.team, path__startswith=f"{instance.path}/").count()
+        return Response({"count": count}, status=status.HTTP_200_OK)
path: posthog/api/file_system.py
user: login: greptile-apps[bot]
body: logic: Consider validating that instance.type is 'folder' before counting child files to avoid misinterpretation if count is called on a file.

```suggestion
    def count(self, request: Request, *args: Any, **kwargs: Any) -> Response:
        """Get count of all files in a folder."""
        instance = self.get_object()
        if instance.type != "folder":
            return Response({"detail": "Count can only be called on folders"}, status=status.HTTP_400_BAD_REQUEST)
        count = FileSystem.objects.filter(team=self.team, path__startswith=f"{instance.path}/").count()
        return Response({"count": count}, status=status.HTTP_200_OK)
```
created_at: 2025-04-01T13:07:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -478,6 +478,20 @@ def test_move_files_and_folders(self):
         file2.refresh_from_db()
         self.assertEqual(file2.path, "NewFolder/File2")
 
+    def test_count_of_files(self):
+        """
+        Moving a folder should update all child paths correctly.
+        """
+        # Create a folder and some files inside it
+        folder = FileSystem.objects.create(team=self.team, path="OldFolder", type="folder", created_by=self.user)
+        FileSystem.objects.create(team=self.team, path="OldFolder/File1", type="doc", created_by=self.user)
+        FileSystem.objects.create(team=self.team, path="OldFolder/File2", type="doc", created_by=self.user)
+
+        # Move the folder
+        response = self.client.post(f"/api/projects/{self.team.id}/file_system/{folder.pk}/count")
+        self.assertEqual(response.status_code, status.HTTP_200_OK, response.json())
+        self.assertEqual(response.json()["count"], 2)
+
path: posthog/api/test/test_file_system.py
user: login: greptile-apps[bot]
body: style: Docstring in test_count_of_files should mention counting child files rather than moving folders for clarity.

```suggestion
        """
        Test that the count endpoint returns the correct number of child files in a folder.
        """
        # Create a folder and some files inside it
        folder = FileSystem.objects.create(team=self.team, path="OldFolder", type="folder", created_by=self.user)
        FileSystem.objects.create(team=self.team, path="OldFolder/File1", type="doc", created_by=self.user)
        FileSystem.objects.create(team=self.team, path="OldFolder/File2", type="doc", created_by=self.user)

        # Move the folder
        response = self.client.post(f"/api/projects/{self.team.id}/file_system/{folder.pk}/count")
        self.assertEqual(response.status_code, status.HTTP_200_OK, response.json())
        self.assertEqual(response.json()["count"], 2)
```
created_at: 2025-04-01T13:07:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: posthog-bot
created_at: 2025-04-01T13:21:07Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/30638/commits/07c718d248b9b923ec8fc810ff088994bbb2e2a7), [diff for shard 5](https://github.com/PostHog/posthog/pull/30638/commits/d2e5bbc4f54a78ec09a48336b658552ec8d33093))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30638/commits/c5df86e4fff68c455132d37a89acb2f8d49d1cdf).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30638/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-01T13:31:31Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/30638/commits/a6d741c2376212e3bf6163db0313307c85a2f474), [diff for shard 5](https://github.com/PostHog/posthog/pull/30638/commits/6c6e770a7bebbf21d9f1e3dcb6f449a627e9911c))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30638/commits/f1fd0b27dc3ea4d60e355fadbc35549a07247ede).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30638/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-01T13:40:47Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30638/commits/796c0fd94340039f758aa5a8904d35cb13966234))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30638/commits/b06808cfbab8bae5a2e70c3d849b13247b600d06).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30638/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-01T13:50:09Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/30638/commits/fa134f45e19a828322b8ef7c0f3c2cef8b35e3d4))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/30638/commits/2a76e76a395229244581daeae3693c1cdea5b071).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/30638/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-01T14:44:47Z
body: **Size Change:** +151 B (0%) 

**Total Size:** 9.96 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 9.96 MB | +151 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR