+ {{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }}
+
@@ -55,8 +57,11 @@
@click="onEntryClick(entry)"
>
|
-
- {{ entry.name }}
+
+
+ {{ entry.name }}
+ {{ parentDir(entry) }}
+
|
{{ entry.isDir ? '—' : formatFileSize(entry.size) }} |
{{ formatDate(entry.modifiedAt) }} |
@@ -82,9 +87,11 @@ import { formatFileSize } from '~/utils/format'
useHead({ title: 'Documents' })
-const { browse } = useShareService()
+const { browse, search } = useShareService()
const { enabled, ensureLoaded } = useShareStatus()
+const MIN_SEARCH_LENGTH = 2
+
const currentPath = ref('')
const breadcrumb = ref([])
const entries = ref([])
@@ -92,12 +99,64 @@ const filter = ref('')
const loading = ref(false)
const error = ref(null)
+const searchResults = ref([])
+const searching = ref(false)
+const searchError = ref(null)
+
const previewEntry = ref(null)
+// Recherche globale (récursive sur tout le partage) dès 2 caractères ;
+// en deçà, simple filtre local sur le dossier courant.
+const isSearchMode = computed(() => filter.value.trim().length >= MIN_SEARCH_LENGTH)
+
const visibleEntries = computed(() => {
const f = filter.value.trim().toLowerCase()
if (!f) return entries.value
- return entries.value.filter((e) => e.name.toLowerCase().includes(f))
+ if (f.length < MIN_SEARCH_LENGTH) {
+ return entries.value.filter((e) => e.name.toLowerCase().includes(f))
+ }
+ return searchResults.value
+})
+
+function parentDir(entry: FileEntry): string {
+ const idx = entry.path.lastIndexOf('/')
+ return idx === -1 ? '' : entry.path.slice(0, idx)
+}
+
+let searchTimer: ReturnType | null = null
+let searchSeq = 0
+
+async function runSearch(query: string) {
+ const seq = ++searchSeq
+ searching.value = true
+ searchError.value = null
+ try {
+ const result = await search(query)
+ if (seq !== searchSeq) return // une frappe plus récente a pris le relais
+ searchResults.value = result.entries
+ } catch (e: unknown) {
+ if (seq !== searchSeq) return
+ searchError.value = (e as Error)?.message ?? 'Erreur'
+ searchResults.value = []
+ } finally {
+ if (seq === searchSeq) searching.value = false
+ }
+}
+
+watch(filter, (value) => {
+ if (searchTimer) clearTimeout(searchTimer)
+
+ const q = value.trim()
+ if (q.length < MIN_SEARCH_LENGTH) {
+ searchSeq++ // invalide toute recherche en vol
+ searching.value = false
+ searchError.value = null
+ searchResults.value = []
+ return
+ }
+
+ searching.value = true
+ searchTimer = setTimeout(() => runSearch(q), 300)
})
const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir))
diff --git a/frontend/services/dto/share.ts b/frontend/services/dto/share.ts
index e053ce4..1bcb716 100644
--- a/frontend/services/dto/share.ts
+++ b/frontend/services/dto/share.ts
@@ -18,6 +18,11 @@ export type ShareBrowseResult = {
entries: FileEntry[]
}
+export type ShareSearchResult = {
+ query: string
+ entries: FileEntry[]
+}
+
export type ShareStatus = {
enabled: boolean
}
diff --git a/frontend/services/share.ts b/frontend/services/share.ts
index 2ce7399..b1be61f 100644
--- a/frontend/services/share.ts
+++ b/frontend/services/share.ts
@@ -1,4 +1,4 @@
-import type { ShareBrowseResult, ShareStatus } from './dto/share'
+import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share'
export function useShareService() {
const api = useApi()
@@ -9,6 +9,10 @@ export function useShareService() {
return api.get(`/share/browse${query}`)
}
+ async function search(query: string): Promise {
+ return api.get(`/share/search?q=${encodeURIComponent(query)}`)
+ }
+
async function getStatus(): Promise {
return api.get('/share/status')
}
@@ -18,5 +22,5 @@ export function useShareService() {
return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
}
- return { browse, getStatus, getDownloadUrl }
+ return { browse, search, getStatus, getDownloadUrl }
}
diff --git a/src/Controller/Share/ShareSearchController.php b/src/Controller/Share/ShareSearchController.php
new file mode 100644
index 0000000..aa5512b
--- /dev/null
+++ b/src/Controller/Share/ShareSearchController.php
@@ -0,0 +1,56 @@
+query->get('q', ''));
+
+ if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
+ return new JsonResponse(['query' => $query, 'entries' => []]);
+ }
+
+ try {
+ $entries = $this->fileSource->search($query);
+ } catch (ShareNotConfiguredException) {
+ return new JsonResponse(['error' => 'Share not configured.'], 409);
+ } catch (ShareConnectionException) {
+ return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
+ }
+
+ return new JsonResponse([
+ 'query' => $query,
+ 'entries' => array_map(static fn (FileEntry $e): array => [
+ 'name' => $e->name,
+ 'path' => $e->path,
+ 'isDir' => $e->isDir,
+ 'size' => $e->size,
+ 'modifiedAt' => $e->modifiedAt,
+ 'mimeType' => $e->mimeType,
+ ], $entries),
+ ]);
+ }
+}
diff --git a/src/Service/Share/FileSource.php b/src/Service/Share/FileSource.php
index c848ecf..4143f6a 100644
--- a/src/Service/Share/FileSource.php
+++ b/src/Service/Share/FileSource.php
@@ -11,6 +11,13 @@ interface FileSource
*/
public function dir(string $relativePath): array;
+ /**
+ * Recherche récursive, par fragment de nom (insensible à la casse), dans tout le partage.
+ *
+ * @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom (limités)
+ */
+ public function search(string $query, int $limit = 200): array;
+
/**
* @return resource flux binaire en lecture
*/
diff --git a/src/Service/Share/SmbFileSource.php b/src/Service/Share/SmbFileSource.php
index a66eff1..d4f29fd 100644
--- a/src/Service/Share/SmbFileSource.php
+++ b/src/Service/Share/SmbFileSource.php
@@ -16,8 +16,13 @@ use Icewind\SMB\ServerFactory;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
+use function count;
+
final class SmbFileSource implements FileSource
{
+ /** Garde-fou : nombre maximum de dossiers explorés par recherche (évite de bloquer sur un très gros partage). */
+ private const int SEARCH_MAX_DIRS = 2000;
+
public function __construct(
private readonly ShareConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
@@ -38,17 +43,60 @@ final class SmbFileSource implements FileSource
$entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);
- usort($entries, static function (FileEntry $a, FileEntry $b): int {
- if ($a->isDir !== $b->isDir) {
- return $a->isDir ? -1 : 1;
- }
-
- return strcasecmp($a->name, $b->name);
- });
+ $this->sortEntries($entries);
return $entries;
}
+ public function search(string $query, int $limit = 200): array
+ {
+ $needle = trim($query);
+
+ if ('' === $needle) {
+ return [];
+ }
+
+ $config = $this->requireUsableConfig();
+ $share = $this->connect($config);
+ $base = (string) $config->getBasePath();
+
+ $results = [];
+ $queue = ['']; // chemins relatifs des dossiers à explorer, racine en premier (parcours en largeur)
+ $visitedDirs = 0;
+
+ while ([] !== $queue && count($results) < $limit && $visitedDirs < self::SEARCH_MAX_DIRS) {
+ $relative = array_shift($queue);
+ $full = $this->pathResolver->fullPath($base, $relative);
+
+ try {
+ $infos = $share->dir($full);
+ } catch (Throwable) {
+ continue; // dossier illisible (droits, lien mort…) : on l'ignore et on poursuit
+ }
+ ++$visitedDirs;
+
+ foreach ($infos as $info) {
+ $entry = $this->toEntry($info, $relative);
+
+ if ($entry->isDir) {
+ $queue[] = $entry->path;
+ }
+
+ if (false !== mb_stripos($entry->name, $needle)) {
+ $results[] = $entry;
+
+ if (count($results) >= $limit) {
+ break;
+ }
+ }
+ }
+ }
+
+ $this->sortEntries($results);
+
+ return $results;
+ }
+
public function read(string $relativePath)
{
$config = $this->requireUsableConfig();
@@ -108,6 +156,22 @@ final class SmbFileSource implements FileSource
}
}
+ /**
+ * Trie en place : dossiers d'abord, puis tri alphabétique insensible à la casse.
+ *
+ * @param FileEntry[] $entries
+ */
+ private function sortEntries(array &$entries): void
+ {
+ usort($entries, static function (FileEntry $a, FileEntry $b): int {
+ if ($a->isDir !== $b->isDir) {
+ return $a->isDir ? -1 : 1;
+ }
+
+ return strcasecmp($a->name, $b->name);
+ });
+ }
+
private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
{
$parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';
diff --git a/tests/Functional/Controller/ShareSearchTest.php b/tests/Functional/Controller/ShareSearchTest.php
new file mode 100644
index 0000000..62e9bbd
--- /dev/null
+++ b/tests/Functional/Controller/ShareSearchTest.php
@@ -0,0 +1,53 @@
+request('GET', '/api/share/search?q=rapport');
+
+ self::assertSame(401, $client->getResponse()->getStatusCode());
+ }
+
+ public function testSearchReturnsEmptyForShortQuery(): void
+ {
+ $client = self::createClient();
+ $this->login($client);
+
+ $client->request('GET', '/api/share/search?q=a');
+
+ self::assertResponseIsSuccessful();
+ $data = json_decode($client->getResponse()->getContent(), true);
+ self::assertSame('a', $data['query']);
+ self::assertSame([], $data['entries']);
+ }
+
+ public function testSearchReturns409WhenNotConfigured(): void
+ {
+ $client = self::createClient();
+ $this->login($client);
+
+ $client->request('GET', '/api/share/search?q=rapport');
+
+ self::assertSame(409, $client->getResponse()->getStatusCode());
+ }
+
+ private function login(KernelBrowser $client): void
+ {
+ $em = self::getContainer()->get('doctrine.orm.entity_manager');
+ $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
+ $client->loginUser($user);
+ }
+}