Skip to content

Commit fcc2369

Browse files
committed
fix(files): preserve scoped listing semantics
1 parent e05944c commit fcc2369

6 files changed

Lines changed: 219 additions & 20 deletions

File tree

‎apps/sim/lib/internal/file/operations.test.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ describe('file manage folder wiring', () => {
535535
})
536536
)
537537

538-
expect(mockResolveWorkspaceFileReference).not.toHaveBeenCalledWith('workspace-1', 'b-self')
538+
expect(mockResolveWorkspaceFileReference).toHaveBeenCalledWith('workspace-1', 'a-self')
539539
})
540540

541541
it('passes the replacement through as a string edit', async () => {
@@ -960,6 +960,58 @@ describe('file manage folder wiring', () => {
960960
})
961961
)
962962
})
963+
964+
it('fills a recursive page from shallower files before deeper files', async () => {
965+
mockListWorkspaceFileFolders.mockResolvedValue({ folders: [FOLDER_ROW] })
966+
mockQueryWorkspaceFilePage.mockImplementation(
967+
async ({ input }: { input: { folderScope: { includeRootItems: boolean } } }) =>
968+
input.folderScope.includeRootItems
969+
? {
970+
files: [{ ...workspaceFile('root-z'), name: 'z.txt', folderId: null }],
971+
nextKeys: null,
972+
}
973+
: {
974+
files: [{ ...workspaceFile('nested-a'), name: 'a.txt', folderId: 'folder-reports' }],
975+
nextKeys: null,
976+
}
977+
)
978+
979+
const response = await POST(
980+
createMockRequest('POST', {
981+
operation: 'list',
982+
workspaceId: 'workspace-1',
983+
recursive: true,
984+
limit: 2,
985+
})
986+
)
987+
const body = await response.json()
988+
989+
expect(body.data.entries.map((entry: { name: string }) => entry.name)).toEqual([
990+
'Reports',
991+
'z.txt',
992+
])
993+
expect(body.data.truncated).toBe(true)
994+
expect(mockQueryWorkspaceFilePage).toHaveBeenNthCalledWith(
995+
1,
996+
expect.objectContaining({
997+
input: expect.objectContaining({
998+
folderScope: { folderIds: new Set<string>(), includeRootItems: true },
999+
}),
1000+
})
1001+
)
1002+
expect(mockQueryWorkspaceFilePage).toHaveBeenNthCalledWith(
1003+
2,
1004+
expect.objectContaining({
1005+
input: expect.objectContaining({
1006+
folderScope: {
1007+
folderIds: new Set<string>(['folder-reports']),
1008+
includeRootItems: false,
1009+
},
1010+
limit: 1,
1011+
}),
1012+
})
1013+
)
1014+
})
9631015
})
9641016

9651017
describe('file manage operations', () => {

‎apps/sim/lib/internal/file/operations.ts‎

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@/lib/execution/private-tool-metadata'
2929
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
3030
import { buildFolderPath, parseFolderPath, ROOT_FOLDER_PATH } from '@/lib/folders/paths'
31+
import type { FolderIdScope } from '@/lib/folders/scope'
3132
import { collectFolderDepths } from '@/lib/folders/subtree'
3233
import { ShareValidationError } from '@/lib/public-shares/share-manager'
3334
import {
@@ -125,6 +126,29 @@ export interface FileManageOperationContext {
125126
signal?: AbortSignal
126127
}
127128

129+
function directoryFileScopeForDepthRange(
130+
rootId: string | null,
131+
folderDepths: ReadonlyMap<string, number>,
132+
minDepth: number,
133+
maxDepth: number
134+
): FolderIdScope {
135+
const folderIds = new Set<string>()
136+
137+
if (minDepth <= 0 && maxDepth >= 0 && rootId !== null) folderIds.add(rootId)
138+
for (const [folderId, depth] of folderDepths) {
139+
if (depth >= minDepth && depth <= maxDepth) folderIds.add(folderId)
140+
}
141+
142+
return {
143+
folderIds,
144+
includeRootItems: rootId === null && minDepth <= 0 && maxDepth >= 0,
145+
}
146+
}
147+
148+
function hasDirectoryFileScope(scope: FolderIdScope): boolean {
149+
return scope.includeRootItems || scope.folderIds.size > 0
150+
}
151+
128152
async function assertOperationFileAccess(
129153
file: Pick<UserFile, 'key' | 'context'>,
130154
context: FileManageOperationContext
@@ -1986,27 +2010,105 @@ export async function executeFileManageOperation(
19862010
}
19872011

19882012
const maxDepth = body.recursive ? (body.depth ?? Number.POSITIVE_INFINITY) : 1
1989-
const fileParentDepths = collectFolderDepths(projected, rootId, {
1990-
maxDepth: Math.max(0, maxDepth - 1),
1991-
})
1992-
const folderIds = new Set(fileParentDepths.keys())
1993-
if (rootId) folderIds.add(rootId)
19942013
const limit = body.limit ?? DEFAULT_FILE_LIST_LIMIT
1995-
const filePage = await queryWorkspaceFilePage.execute({
1996-
principal,
1997-
input: {
1998-
workspaceId,
1999-
folderScope: { folderIds, includeRootItems: rootId === null },
2000-
search: body.search,
2001-
sortBy: 'name',
2002-
sortOrder: 'asc',
2003-
limit,
2004-
},
2014+
const folderDepths = collectFolderDepths(projected, rootId, { maxDepth })
2015+
let maxParentDepth = 0
2016+
for (const depth of folderDepths.values()) {
2017+
if (depth < maxDepth) maxParentDepth = Math.max(maxParentDepth, depth)
2018+
}
2019+
2020+
const folderListing = selectDirectoryEntries(projected, [], {
2021+
rootId,
2022+
rootPath,
2023+
maxDepth,
2024+
search: body.search,
2025+
limit: projected.length,
20052026
})
2027+
const matchingFolderCountByDepth = new Map<number, number>()
2028+
for (const entry of folderListing.entries) {
2029+
matchingFolderCountByDepth.set(
2030+
entry.depth,
2031+
(matchingFolderCountByDepth.get(entry.depth) ?? 0) + 1
2032+
)
2033+
}
2034+
2035+
const files: WorkspaceFileRecord[] = []
2036+
let processedMatchingFolders = 0
2037+
let fileListingTruncated = false
2038+
2039+
const queryFileScope = (folderScope: FolderIdScope, pageLimit: number) =>
2040+
queryWorkspaceFilePage.execute({
2041+
principal,
2042+
input: {
2043+
workspaceId,
2044+
folderScope,
2045+
search: body.search,
2046+
sortBy: 'name',
2047+
sortOrder: 'asc',
2048+
limit: pageLimit,
2049+
},
2050+
})
2051+
2052+
for (let fileDepth = 1; fileDepth <= maxParentDepth + 1; fileDepth++) {
2053+
processedMatchingFolders += matchingFolderCountByDepth.get(fileDepth) ?? 0
2054+
const parentDepth = fileDepth - 1
2055+
const knownEntryCount = processedMatchingFolders + files.length
2056+
2057+
if (knownEntryCount >= limit) {
2058+
fileListingTruncated =
2059+
knownEntryCount > limit || folderListing.entries.length > processedMatchingFolders
2060+
if (!fileListingTruncated) {
2061+
const remainingScope = directoryFileScopeForDepthRange(
2062+
rootId,
2063+
folderDepths,
2064+
parentDepth,
2065+
maxParentDepth
2066+
)
2067+
if (hasDirectoryFileScope(remainingScope)) {
2068+
const remainingPage = await queryFileScope(remainingScope, 1)
2069+
fileListingTruncated = remainingPage.files.length > 0
2070+
}
2071+
}
2072+
break
2073+
}
2074+
2075+
const depthScope = directoryFileScopeForDepthRange(
2076+
rootId,
2077+
folderDepths,
2078+
parentDepth,
2079+
parentDepth
2080+
)
2081+
if (!hasDirectoryFileScope(depthScope)) continue
2082+
2083+
const filePage = await queryFileScope(depthScope, limit)
2084+
files.push(...filePage.files)
2085+
2086+
const populatedEntryCount = processedMatchingFolders + files.length
2087+
if (filePage.nextKeys !== null || populatedEntryCount > limit) {
2088+
fileListingTruncated = true
2089+
break
2090+
}
2091+
if (populatedEntryCount === limit) {
2092+
fileListingTruncated = folderListing.entries.length > processedMatchingFolders
2093+
if (!fileListingTruncated && parentDepth < maxParentDepth) {
2094+
const remainingScope = directoryFileScopeForDepthRange(
2095+
rootId,
2096+
folderDepths,
2097+
parentDepth + 1,
2098+
maxParentDepth
2099+
)
2100+
if (hasDirectoryFileScope(remainingScope)) {
2101+
const remainingPage = await queryFileScope(remainingScope, 1)
2102+
fileListingTruncated = remainingPage.files.length > 0
2103+
}
2104+
}
2105+
break
2106+
}
2107+
}
20062108

20072109
const listing = selectDirectoryEntries(
20082110
projected,
2009-
filePage.files.map((file) => ({
2111+
files.map((file) => ({
20102112
id: file.id,
20112113
name: file.name,
20122114
folderId: file.folderId ?? null,
@@ -2028,7 +2130,7 @@ export async function executeFileManageOperation(
20282130
data: {
20292131
path: rootPath,
20302132
entries: listing.entries,
2031-
truncated: listing.truncated || filePage.nextKeys !== null,
2133+
truncated: listing.truncated || fileListingTruncated,
20322134
},
20332135
})
20342136
}

‎apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ describe('workspace file reference application service', () => {
106106
expect(mocks.resolvePermission).toHaveBeenCalledTimes(1)
107107
})
108108

109+
it('reads an exact name directly inside a canonical folder id', async () => {
110+
await expect(
111+
readWorkspaceFileReference({
112+
principal,
113+
workspaceId: 'workspace-1',
114+
reference: 'source.txt',
115+
folderId: 'folder-1',
116+
maxBytes: 512,
117+
})
118+
).resolves.toEqual({ file, content: Buffer.from('source') })
119+
120+
expect(mocks.getByName).toHaveBeenCalledWith('workspace-1', 'source.txt', {
121+
folderId: 'folder-1',
122+
})
123+
expect(mocks.resolveStoredReference).not.toHaveBeenCalled()
124+
expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 })
125+
})
126+
109127
it('fails before canonical loading for an unregistered operation object', async () => {
110128
const duplicateOperation = defineWorkspaceOperation({
111129
id: fileOperations.rename.id,

‎apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,16 @@ export async function readWorkspaceFileReference({
129129
principal,
130130
workspaceId,
131131
reference,
132+
folderId,
132133
maxBytes,
133134
}: ReadWorkspaceFileReferenceInput): Promise<{ file: WorkspaceFileRecord; content: Buffer }> {
134135
return readWorkspaceFileReferenceUseCase.execute({
135136
principal,
136-
input: { workspaceId, reference, maxBytes },
137+
input: {
138+
workspaceId,
139+
reference,
140+
maxBytes,
141+
...(folderId === undefined ? {} : { folderId }),
142+
},
137143
})
138144
}

‎apps/sim/lib/workspace-files/edit-content.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,25 @@ describe('applyWorkspaceFileContentEdit', () => {
104104
).toBe('heading\r\n anchor \r\none\r\ntwo\r\ntail\r\n')
105105
})
106106

107+
it('does not add a blank line when anchored content ends with a newline', () => {
108+
expect(
109+
applyWorkspaceFileContentEdit('before\nold\nafter\n', {
110+
mode: 'replace_between',
111+
beforeAnchor: 'before',
112+
afterAnchor: 'after',
113+
content: 'new\n',
114+
})
115+
).toBe('before\nnew\nafter\n')
116+
117+
expect(
118+
applyWorkspaceFileContentEdit('anchor\ntail\n', {
119+
mode: 'insert_after',
120+
anchor: 'anchor',
121+
content: 'new\n',
122+
})
123+
).toBe('anchor\nnew\ntail\n')
124+
})
125+
107126
it('deletes the start anchor and interior while preserving the end anchor', () => {
108127
expect(
109128
applyWorkspaceFileContentEdit('before\nstart\nremove\nend\nafter', {

‎apps/sim/lib/workspace-files/edit-content.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ function anchorLineIndex(lines: string[], anchor: string, occurrence: number): n
171171
}
172172

173173
function contentLines(content: string): string[] {
174-
return content.length === 0 ? [] : splitLines(content)
174+
if (content.length === 0) return []
175+
const lines = splitLines(content)
176+
return lines.at(-1) === '' ? lines.slice(0, -1) : lines
175177
}
176178

177179
/**

0 commit comments

Comments
 (0)