fix(sidebar): prevent home-project archived session overlap crash (#2017)
* fix(sidebar): scope archived sessions to deepest project * fix(sidebar): prefer session directory over worktree --------- Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
bashrusakh
parent
e5bba59a75
commit
fbcf4ea2b9
@@ -1,7 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils';
|
import {
|
||||||
|
collectKnownProjectDirectories,
|
||||||
|
dedupeSessionsById,
|
||||||
|
getArchivedScopeKey,
|
||||||
|
isSessionRelatedToProject,
|
||||||
|
normalizePath,
|
||||||
|
resolveArchivedFolderName,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
type ProjectForArchivedFolders = {
|
type ProjectForArchivedFolders = {
|
||||||
normalizedPath: string;
|
normalizedPath: string;
|
||||||
@@ -28,7 +35,9 @@ type Args = {
|
|||||||
|
|
||||||
const getArchivedSessionsForProject = (
|
const getArchivedSessionsForProject = (
|
||||||
project: ProjectForArchivedFolders,
|
project: ProjectForArchivedFolders,
|
||||||
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'>,
|
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'> & {
|
||||||
|
knownProjectDirectories: Set<string>;
|
||||||
|
},
|
||||||
): Session[] => {
|
): Session[] => {
|
||||||
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||||
const validDirectories = new Set<string>([
|
const validDirectories = new Set<string>([
|
||||||
@@ -39,7 +48,7 @@ const getArchivedSessionsForProject = (
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories),
|
||||||
);
|
);
|
||||||
|
|
||||||
const archived = collect(params.archivedSessions);
|
const archived = collect(params.archivedSessions);
|
||||||
@@ -51,7 +60,7 @@ const getArchivedSessionsForProject = (
|
|||||||
if (sessionDirectory) {
|
if (sessionDirectory) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories);
|
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories);
|
||||||
});
|
});
|
||||||
|
|
||||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||||
@@ -71,6 +80,11 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
|||||||
cleanupSessions,
|
cleanupSessions,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
|
const knownProjectDirectories = React.useMemo(
|
||||||
|
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||||
|
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSessionsLoading) {
|
if (isSessionsLoading) {
|
||||||
return;
|
return;
|
||||||
@@ -83,6 +97,7 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
|||||||
archivedSessions,
|
archivedSessions,
|
||||||
availableWorktreesByProject,
|
availableWorktreesByProject,
|
||||||
isVSCode,
|
isVSCode,
|
||||||
|
knownProjectDirectories,
|
||||||
});
|
});
|
||||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||||
|
|
||||||
@@ -110,6 +125,7 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
|||||||
sessions,
|
sessions,
|
||||||
archivedSessions,
|
archivedSessions,
|
||||||
availableWorktreesByProject,
|
availableWorktreesByProject,
|
||||||
|
knownProjectDirectories,
|
||||||
isVSCode,
|
isVSCode,
|
||||||
isSessionsLoading,
|
isSessionsLoading,
|
||||||
foldersMap,
|
foldersMap,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
|
import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
|
||||||
|
|
||||||
type WorktreeMeta = { path: string };
|
type WorktreeMeta = { path: string };
|
||||||
|
|
||||||
@@ -37,23 +37,10 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
// worktree. Walking this set is O(P + W) per Sidebar render and lets
|
// worktree. Walking this set is O(P + W) per Sidebar render and lets
|
||||||
// us skip the bulk of `sessions` (whose directory is not associated
|
// us skip the bulk of `sessions` (whose directory is not associated
|
||||||
// with a known project) when building `sessionsByDirectory`.
|
// with a known project) when building `sessionsByDirectory`.
|
||||||
const allowedDirectories = React.useMemo(() => {
|
const knownProjectDirectories = React.useMemo(
|
||||||
const set = new Set<string>();
|
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||||
normalizedProjects.forEach((project) => {
|
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||||
if (project.normalizedPath) {
|
);
|
||||||
set.add(project.normalizedPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!isVSCode) {
|
|
||||||
for (const worktrees of availableWorktreesByProject.values()) {
|
|
||||||
for (const worktree of worktrees) {
|
|
||||||
const normalized = normalizePath(worktree.path);
|
|
||||||
if (normalized) set.add(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}, [normalizedProjects, availableWorktreesByProject, isVSCode]);
|
|
||||||
|
|
||||||
const sessionsByDirectory = React.useMemo(() => {
|
const sessionsByDirectory = React.useMemo(() => {
|
||||||
const next = new Map<string, Session[]>();
|
const next = new Map<string, Session[]>();
|
||||||
@@ -67,7 +54,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
// every session the server has ever seen, even ones for
|
// every session the server has ever seen, even ones for
|
||||||
// long-removed worktrees; the sidebar's downstream filters
|
// long-removed worktrees; the sidebar's downstream filters
|
||||||
// would then drop them anyway.
|
// would then drop them anyway.
|
||||||
if (!allowedDirectories.has(directory)) {
|
if (!knownProjectDirectories.has(directory)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +63,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
next.set(directory, collection);
|
next.set(directory, collection);
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [sessions, allowedDirectories]);
|
}, [sessions, knownProjectDirectories]);
|
||||||
|
|
||||||
const getSessionsForProject = React.useCallback(
|
const getSessionsForProject = React.useCallback(
|
||||||
(project: { normalizedPath: string }) => {
|
(project: { normalizedPath: string }) => {
|
||||||
@@ -92,7 +79,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
const collected: Session[] = [];
|
const collected: Session[] = [];
|
||||||
|
|
||||||
directories.forEach((directory) => {
|
directories.forEach((directory) => {
|
||||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? [];
|
const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? [];
|
||||||
sessionsForDirectory.forEach((session) => {
|
sessionsForDirectory.forEach((session) => {
|
||||||
if (seen.has(session.id)) {
|
if (seen.has(session.id)) {
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +132,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories),
|
||||||
);
|
);
|
||||||
|
|
||||||
const archived = collect(archivedSessions);
|
const archived = collect(archivedSessions);
|
||||||
@@ -161,12 +148,12 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
if (!projectWorktree) {
|
if (!projectWorktree) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`);
|
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
|
||||||
});
|
});
|
||||||
|
|
||||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||||
},
|
},
|
||||||
[archivedSessions, availableWorktreesByProject, isVSCode, sessions],
|
[archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath } from '../utils';
|
import {
|
||||||
|
collectKnownProjectDirectories,
|
||||||
|
dedupeSessionsById,
|
||||||
|
getArchivedScopeKey,
|
||||||
|
isSessionRelatedToProject,
|
||||||
|
normalizePath,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
type NormalizedProject = {
|
type NormalizedProject = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +39,11 @@ export const useSessionFolderCleanup = (args: Args): void => {
|
|||||||
cleanupSessions,
|
cleanupSessions,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
|
const knownProjectDirectories = React.useMemo(
|
||||||
|
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||||
|
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSessionsLoading || !hasLoadedGlobalSessions) {
|
if (isSessionsLoading || !hasLoadedGlobalSessions) {
|
||||||
return;
|
return;
|
||||||
@@ -76,9 +87,9 @@ export const useSessionFolderCleanup = (args: Args): void => {
|
|||||||
if (sessionDirectory) {
|
if (sessionDirectory) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories);
|
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
|
||||||
}),
|
}),
|
||||||
]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories));
|
]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories));
|
||||||
|
|
||||||
idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
|
idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
|
||||||
});
|
});
|
||||||
@@ -95,6 +106,7 @@ export const useSessionFolderCleanup = (args: Args): void => {
|
|||||||
hasLoadedGlobalSessions,
|
hasLoadedGlobalSessions,
|
||||||
isSessionsLoading,
|
isSessionsLoading,
|
||||||
isVSCode,
|
isVSCode,
|
||||||
|
knownProjectDirectories,
|
||||||
normalizedProjects,
|
normalizedProjects,
|
||||||
sessions,
|
sessions,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { isPathWithinProject } from './utils';
|
import { isPathWithinProject, isSessionRelatedToProject } from './utils';
|
||||||
|
|
||||||
describe('isPathWithinProject', () => {
|
describe('isPathWithinProject', () => {
|
||||||
test('matches child directories for root projects', () => {
|
test('matches child directories for root projects', () => {
|
||||||
@@ -27,3 +28,86 @@ describe('isPathWithinProject', () => {
|
|||||||
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
|
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSessionRelatedToProject', () => {
|
||||||
|
test('prefers the most specific project root for archived session directories', () => {
|
||||||
|
const session = {
|
||||||
|
id: 'ses_parent_child',
|
||||||
|
directory: '/home/user/proj/foo/src',
|
||||||
|
} as unknown as Session;
|
||||||
|
|
||||||
|
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(
|
||||||
|
session,
|
||||||
|
'/home/user/proj/foo',
|
||||||
|
new Set(['/home/user/proj/foo']),
|
||||||
|
knownProjectDirectories,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers the most specific project worktree when session directory is missing', () => {
|
||||||
|
const session = {
|
||||||
|
id: 'ses_project_worktree',
|
||||||
|
project: {
|
||||||
|
worktree: '/home/user/proj/foo',
|
||||||
|
},
|
||||||
|
} as unknown as Session;
|
||||||
|
|
||||||
|
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(
|
||||||
|
session,
|
||||||
|
'/home/user/proj/foo',
|
||||||
|
new Set(['/home/user/proj/foo']),
|
||||||
|
knownProjectDirectories,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers explicit session directory over broader project worktree metadata', () => {
|
||||||
|
const session = {
|
||||||
|
id: 'ses_directory_beats_worktree',
|
||||||
|
directory: '/home/user/proj/foo/src',
|
||||||
|
project: {
|
||||||
|
worktree: '/home/user',
|
||||||
|
},
|
||||||
|
} as unknown as Session;
|
||||||
|
|
||||||
|
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(
|
||||||
|
session,
|
||||||
|
'/home/user/proj/foo',
|
||||||
|
new Set(['/home/user/proj/foo']),
|
||||||
|
knownProjectDirectories,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps descendant sessions on the broad project when no child project matches', () => {
|
||||||
|
const session = {
|
||||||
|
id: 'ses_home_misc',
|
||||||
|
directory: '/home/user/misc/sandbox',
|
||||||
|
} as unknown as Session;
|
||||||
|
|
||||||
|
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -94,6 +94,61 @@ export const isPathWithinProject = (directory?: string | null, projectPath?: str
|
|||||||
return normalizedDirectory.startsWith(`${normalizedProjectPath}/`);
|
return normalizedDirectory.startsWith(`${normalizedProjectPath}/`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizedProjectPath = { normalizedPath: string };
|
||||||
|
type WorktreePath = { path: string };
|
||||||
|
|
||||||
|
export const collectKnownProjectDirectories = (
|
||||||
|
normalizedProjects: NormalizedProjectPath[],
|
||||||
|
availableWorktreesByProject: Map<string, WorktreePath[]>,
|
||||||
|
isVSCode: boolean,
|
||||||
|
): Set<string> => {
|
||||||
|
const knownDirectories = new Set<string>();
|
||||||
|
|
||||||
|
normalizedProjects.forEach((project) => {
|
||||||
|
if (project.normalizedPath) {
|
||||||
|
knownDirectories.add(project.normalizedPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isVSCode) {
|
||||||
|
return knownDirectories;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const worktrees of availableWorktreesByProject.values()) {
|
||||||
|
for (const worktree of worktrees) {
|
||||||
|
const normalized = normalizePath(worktree.path);
|
||||||
|
if (normalized) {
|
||||||
|
knownDirectories.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return knownDirectories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findBestProjectDirectoryMatch = (
|
||||||
|
value: string | null,
|
||||||
|
knownDirectories?: Iterable<string>,
|
||||||
|
): string | null => {
|
||||||
|
if (!value || !knownDirectories) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestMatch: string | null = null;
|
||||||
|
for (const candidate of knownDirectories) {
|
||||||
|
const normalizedCandidate = normalizePath(candidate);
|
||||||
|
if (!normalizedCandidate || !isPathWithinProject(value, normalizedCandidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMatch || normalizedCandidate.length > bestMatch.length) {
|
||||||
|
bestMatch = normalizedCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeForBranchComparison = (value: string): string => {
|
export const normalizeForBranchComparison = (value: string): string => {
|
||||||
return value
|
return value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -177,21 +232,26 @@ export const isSessionRelatedToProject = (
|
|||||||
session: Session,
|
session: Session,
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
validDirectories?: Set<string>,
|
validDirectories?: Set<string>,
|
||||||
|
knownDirectories?: Iterable<string>,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||||
|
const resolvedDirectory = sessionDirectory ?? projectWorktree;
|
||||||
|
|
||||||
if (projectWorktree && (projectWorktree === projectRoot || projectWorktree.startsWith(`${projectRoot}/`))) {
|
if (resolvedDirectory && validDirectories?.has(resolvedDirectory)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionDirectory) {
|
if (!resolvedDirectory) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (validDirectories && validDirectories.has(sessionDirectory)) {
|
|
||||||
return true;
|
const bestMatch = findBestProjectDirectoryMatch(resolvedDirectory, knownDirectories);
|
||||||
|
if (bestMatch) {
|
||||||
|
return validDirectories ? validDirectories.has(bestMatch) : bestMatch === projectRoot;
|
||||||
}
|
}
|
||||||
return sessionDirectory === projectRoot || sessionDirectory.startsWith(`${projectRoot}/`);
|
|
||||||
|
return resolvedDirectory === projectRoot || resolvedDirectory.startsWith(`${projectRoot}/`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user