Compare commits

..

1 Commits

Author SHA1 Message Date
Michel Heusschen
5fb8f9bf1a fix(web): prevent horizontal scroll bar in asset viewer side panel (#27270)
* fix(web): prevent horizontal scroll bar in asset viewer side panel

* simplify
2026-03-25 21:02:31 -05:00
7 changed files with 21 additions and 391 deletions

52
pnpm-lock.yaml generated
View File

@@ -431,8 +431,8 @@ importers:
specifier: ^5.51.0
version: 5.70.4
chokidar:
specifier: ^5.0.0
version: 5.0.0
specifier: ^4.0.3
version: 4.0.3
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@@ -592,7 +592,7 @@ importers:
version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(esbuild@0.27.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@5.0.0)(typescript@5.9.3)
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.0.4
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
@@ -6050,10 +6050,6 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -10544,10 +10540,6 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
recma-build-jsx@1.0.0:
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
@@ -12610,17 +12602,6 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/core@19.2.17(chokidar@5.0.0)':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
jsonc-parser: 3.3.1
picomatch: 4.0.2
rxjs: 7.8.1
source-map: 0.7.4
optionalDependencies:
chokidar: 5.0.0
'@angular-devkit/core@19.2.19(chokidar@4.0.3)':
dependencies:
ajv: 8.17.1
@@ -12654,16 +12635,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@angular-devkit/schematics@19.2.17(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
jsonc-parser: 3.3.1
magic-string: 0.30.17
ora: 5.4.1
rxjs: 7.8.1
transitivePeerDependencies:
- chokidar
'@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
@@ -15749,17 +15720,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/schematics@11.0.9(chokidar@5.0.0)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
'@angular-devkit/schematics': 19.2.17(chokidar@5.0.0)
comment-json: 4.4.1
jsonc-parser: 3.3.1
pluralize: 8.0.0
typescript: 5.9.3
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
@@ -18481,10 +18441,6 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@1.1.4: {}
chownr@2.0.0: {}
@@ -23716,8 +23672,6 @@ snapshots:
readdirp@4.1.2: {}
readdirp@5.0.0: {}
recma-build-jsx@1.0.0:
dependencies:
'@types/estree': 1.0.8

View File

@@ -64,7 +64,7 @@
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^5.0.0",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.0",
"compression": "^1.8.0",

View File

@@ -26,21 +26,6 @@ import { JobOf } from 'src/types';
import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc';
const createMatchers = (exclusionPatterns: string[]) => {
const supportedExtensions = mimeTypes.getSupportedFileExtensions().map((extension) => extension.toLowerCase());
const expandedPatterns = exclusionPatterns.flatMap((pattern) =>
pattern.endsWith('/**') ? [pattern, pattern.slice(0, -3)] : [pattern],
);
const excludeMatcher = picomatch(expandedPatterns, { nocase: true });
return {
isExcluded: (path: string) => excludeMatcher(path.replaceAll('\\', '/')),
isSupported: (path: string) => {
const normalizedPath = path.toLowerCase();
return supportedExtensions.some((extension) => normalizedPath.endsWith(extension));
},
};
};
@Injectable()
export class LibraryService extends BaseService {
private watchLibraries = false;
@@ -105,24 +90,24 @@ export class LibraryService extends BaseService {
this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`);
const { isExcluded, isSupported } = createMatchers(library.exclusionPatterns);
const matcher = picomatch(`**/*{${mimeTypes.getSupportedFileExtensions().join(',')}}`, {
nocase: true,
ignore: library.exclusionPatterns,
});
let _resolve: () => void;
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
const handler = async (event: string, path: string) => {
const ignored = !isSupported(path);
if (ignored) {
if (matcher(path)) {
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: [path] },
});
} else {
this.logger.verbose(`Ignoring file ${event} event for ${path} in library ${library.id}`);
return;
}
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: [path] },
});
};
const deletionHandler = async (path: string) => {
@@ -138,7 +123,6 @@ export class LibraryService extends BaseService {
{
usePolling: false,
ignoreInitial: true,
ignored: isExcluded,
awaitWriteFinish: {
stabilityThreshold: 5000,
pollInterval: 1000,

View File

@@ -30,7 +30,6 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MapRepository } from 'src/repositories/map.repository';
@@ -406,7 +405,6 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case LibraryRepository:
case MemoryRepository:
case NotificationRepository:
case OcrRepository:
@@ -468,7 +466,6 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case LibraryRepository:
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:

View File

@@ -1,135 +0,0 @@
import { Kysely } from 'kysely';
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(StorageRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const watchForEvent = (
sut: StorageRepository,
folder: string,
event: 'add' | 'change' | 'unlink',
action: () => Promise<void>,
): Promise<string> => {
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
void close().finally(() => reject(new Error(`Timed out waiting for ${event} event`)));
}, 10_000);
const onResolve = (path: string) => {
clearTimeout(timeout);
void close().finally(() => resolve(path));
};
const onReject = (error: Error) => {
clearTimeout(timeout);
void close().finally(() => reject(error));
};
const close = sut.watch(
[folder],
{
ignoreInitial: true,
usePolling: true,
interval: 50,
},
{
onReady: () => {
void action().catch((error) => onReject(error as Error));
},
onAdd: (path) => {
if (event === 'add') {
onResolve(path);
}
},
onChange: (path) => {
if (event === 'change') {
onResolve(path);
}
},
onUnlink: (path) => {
if (event === 'unlink') {
onResolve(path);
}
},
onError: (error) => onReject(error),
},
);
});
};
describe(StorageRepository.name, () => {
describe('watch', () => {
it('should fire create (add) events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-add-'));
const file = join(folder, 'created.jpg');
try {
const changedPath = await watchForEvent(sut, folder, 'add', async () => {
await writeFile(file, 'created');
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
it('should fire change events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-change-'));
const file = join(folder, 'changed.jpg');
await writeFile(file, 'before');
try {
const changedPath = await watchForEvent(sut, folder, 'change', async () => {
await writeFile(file, 'after');
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
it('should fire unlink events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-unlink-'));
const file = join(folder, 'deleted.jpg');
await writeFile(file, 'content');
try {
const changedPath = await watchForEvent(sut, folder, 'unlink', async () => {
await unlink(file);
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
});
});

View File

@@ -1,169 +0,0 @@
import { ChokidarOptions } from 'chokidar';
import { Kysely } from 'kysely';
import { JobName } from 'src/enum';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { LibraryService } from 'src/services/library.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(LibraryService, {
database: db || defaultDatabase,
real: [LibraryRepository],
mock: [EventRepository, JobRepository, LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const closeWatcher = () => Promise.resolve();
const setWatchMode = (sut: LibraryService) => {
const service = sut as unknown as { lock: boolean; watchLibraries: boolean };
service.lock = true;
service.watchLibraries = true;
};
const makeMockWatcher =
(paths: { add?: string[]; change?: string[]; unlink?: string[] }) =>
(_paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>): (() => Promise<void>) => {
const ignored = options.ignored as ((path: string) => boolean) | undefined;
events.onReady?.();
for (const path of paths.add ?? []) {
if (!ignored?.(path)) {
events.onAdd?.(path);
}
}
for (const path of paths.change ?? []) {
if (!ignored?.(path)) {
events.onChange?.(path);
}
}
for (const path of paths.unlink ?? []) {
if (!ignored?.(path)) {
events.onUnlink?.(path);
}
}
return closeWatcher;
};
describe(`${LibraryService.name} (watch, medium)`, () => {
it('should queue add and change events for supported files', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
const { user } = await ctx.newUser();
const library = await sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
});
storageRepo.watch.mockImplementation(
makeMockWatcher({
add: ['/test-assets/temp/watcher-behavior/add.png'],
change: ['/test-assets/temp/watcher-behavior/change.jpg'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/add.png'] },
});
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/change.jpg'] },
});
await sut.onShutdown();
});
it('should queue unlink events for tracked files', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
const { user } = await ctx.newUser();
const library = await sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
});
storageRepo.watch.mockImplementation(
makeMockWatcher({
unlink: ['/test-assets/temp/watcher-behavior/delete.png'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibraryRemoveAsset,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/delete.png'] },
});
await sut.onShutdown();
});
it('should ignore add, change, and unlink events in excluded directories', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
await ctx.newUser().then(({ user }) =>
sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
}),
);
storageRepo.watch.mockImplementation(
makeMockWatcher({
add: ['/test-assets/temp/watcher-behavior/@eaDir/add.png'],
change: ['/test-assets/temp/watcher-behavior/@eaDir/change.png'],
unlink: ['/test-assets/temp/watcher-behavior/@eaDir/unlink.png'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).not.toHaveBeenCalledWith(
expect.objectContaining({
name: JobName.LibrarySyncFiles,
}),
);
expect(jobRepo.queue).not.toHaveBeenCalledWith(
expect.objectContaining({
name: JobName.LibraryRemoveAsset,
}),
);
await sut.onShutdown();
});
});

View File

@@ -580,17 +580,16 @@
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
showDetailPanel ? 'w-90' : 'w-100',
]}
translate="yes"
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
</div>
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
<EditorPanel {asset} onClose={closeEditor} />
{/if}
</div>
{/if}