The extras control accepts a file — the field and its label said so — and the listing under it could not show one, because the Host primitive behind the picker browses folders and nothing else. So a grant for a single file meant typing its name exactly, which is the one thing a listing exists to avoid. The Host has a second lister and it does return files: `remote.workspaceFiles.list`, the same listing behind the sidebar's own file tree, confined to the session's workspace. The extras picker now takes that one while the level is inside the session folder — which is where an extra usually is — and falls back to the folder lister above it, saying so in the dialog rather than letting file rows vanish. The focus picker never offers files: a focus is a folder, so a file row there would offer something that cannot be chosen. The two listings normalise into one level shape, so the picker does not care which answered, and a row now carries its type: a folder is a way down, a file is a thing to take. Clicking a file fills the path field and marks the row, and the footer adds it in declared form. Pressing Enter on a name the level already lists is no longer reported as a failure — the path is exactly what the dialog is for. Everything stays additive: with no workspace file service, or above the session folder, the picker lists folders exactly as before.
This commit is contained in:
1 parent
a40040ec17
commit
c49acfdb2c
4 files changed
+590
-82
No files matched your search
+281
-41
@@ -46,6 +46,7 @@ window.__ModuleLoader__.load({
|
||||
SettingsFormModel,
|
||||
SettingsValueField,
|
||||
IconFolderCloseRegular,
|
||||
FileTypeIcon,
|
||||
} = primitives
|
||||
|
||||
const h = react.createElement
|
||||
@@ -448,7 +449,10 @@ window.__ModuleLoader__.load({
|
||||
*
|
||||
* The Host's own business message is preferred to the transport error,
|
||||
* because a browse refusal is an answer — the folder is unreadable, the path
|
||||
* is not absolute — and that sentence is the one worth reading.
|
||||
* is not absolute — and that sentence is the one worth reading. Both listers
|
||||
* reach here: the folder primitive rejects with its own error and the remote
|
||||
* one folds its failure into a `RemoteError`, which is an `Error` carrying
|
||||
* the same message field.
|
||||
*/
|
||||
function browseFailure(error) {
|
||||
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
|
||||
@@ -456,6 +460,121 @@ window.__ModuleLoader__.load({
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// ── one level, in the picker's own vocabulary ────────────────────────────
|
||||
//
|
||||
// The Host has two listers and they answer different questions, so the
|
||||
// picker reads them through one shape and does not care which answered:
|
||||
//
|
||||
// * `uiWorkspace.listDirectory` browses anywhere and returns folders only;
|
||||
// * `remote.workspaceFiles.list` returns files as well, and confines itself
|
||||
// to the session's workspace — the same listing the sidebar's file tree
|
||||
// drives, and the only way this deployment can show a file at all.
|
||||
//
|
||||
// `{base, crumbs, entries, truncated, files}` is that shape. `files` says
|
||||
// whether this level could carry file rows, which is what the copy under the
|
||||
// path field tells the user.
|
||||
|
||||
/** Join one name onto a directory, with the separator the Host resolves. */
|
||||
function joinUnder(base, name) {
|
||||
return `${String(base).replace(/[\\/]+$/, '')}/${name}`
|
||||
}
|
||||
|
||||
/** Whether a path is a directory or sits inside one, either separator. */
|
||||
function withinPath(inner, outer) {
|
||||
const path = String(inner).replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const base = String(outer).replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
return base !== '' && (path === base || path.startsWith(`${base}/`))
|
||||
}
|
||||
|
||||
/** The last segment of a path, for a trail's first crumb. */
|
||||
function basenameOf(path) {
|
||||
const segments = pathSegments(path)
|
||||
return segments.length === 0 ? String(path) : segments[segments.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* The ancestry of one path inside the workspace, workspace root first.
|
||||
*
|
||||
* The folder primitive reports its own trail — from the filesystem root, so
|
||||
* the user can go above the session — and the file listing reports none,
|
||||
* because the workspace root is as far up as it goes. This builds the one it
|
||||
* does not send.
|
||||
*/
|
||||
function trailTo(workspace, path) {
|
||||
const trail = [{ name: basenameOf(workspace), path: workspace }]
|
||||
const rest = relativeTo(workspace, path)
|
||||
// A path outside the workspace cannot be walked to from it, and `relativeTo`
|
||||
// says so by answering in `..` segments or absolutely.
|
||||
if (rest === '' || rest.startsWith('..') || isAbsolutePath(rest)) return trail
|
||||
let walked = workspace
|
||||
for (const segment of rest.split('/')) {
|
||||
if (segment === '') continue
|
||||
walked = joinUnder(walked, segment)
|
||||
trail.push({ name: segment, path: walked })
|
||||
}
|
||||
return trail
|
||||
}
|
||||
|
||||
/**
|
||||
* One folder listing, as the picker reads it.
|
||||
*
|
||||
* @param {object} listing - a `DirectoryListing` from the browse primitive.
|
||||
* @param {string} [fallback] - the path asked for, when the answer carries none.
|
||||
* @returns {object} the picker's level.
|
||||
*/
|
||||
function folderListing(listing, fallback) {
|
||||
const base = typeof listing?.path === 'string' && listing.path !== '' ? listing.path : String(fallback ?? '')
|
||||
const home = typeof listing?.home === 'string' ? listing.home : ''
|
||||
return {
|
||||
base,
|
||||
crumbs: (Array.isArray(listing?.crumbs) ? listing.crumbs : []).map((crumb) => ({
|
||||
// A root crumb carries its whole path as its name; "Home" is the label
|
||||
// the host's own browser gives it.
|
||||
name: crumb.path === home ? 'Home' : crumb.name,
|
||||
path: crumb.path,
|
||||
})),
|
||||
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
directory: true,
|
||||
hidden: entry.hidden === true,
|
||||
})),
|
||||
truncated: listing?.truncated === true,
|
||||
files: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One workspace listing, as the picker reads it.
|
||||
*
|
||||
* Paths arrive relative to the workspace root and the picker works in
|
||||
* absolute paths, so they are joined here — with `/`, whatever the level's
|
||||
* own separators, because the Host resolves mixed separators and this is only
|
||||
* a key and something to display.
|
||||
*
|
||||
* @param {object} listing - a `WorkspaceDirectoryListing`.
|
||||
* @param {string} workspace - the session folder, absolute.
|
||||
* @returns {object} the picker's level.
|
||||
*/
|
||||
function workspaceListing(listing, workspace) {
|
||||
const relative = typeof listing?.path === 'string' ? listing.path : ''
|
||||
const base = relative === '' ? workspace : joinUnder(workspace, relative)
|
||||
return {
|
||||
base,
|
||||
crumbs: trailTo(workspace, base),
|
||||
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
|
||||
name: entry.name,
|
||||
path: joinUnder(base, entry.name),
|
||||
directory: entry.type === 'directory',
|
||||
// The workspace listing has no hidden flag; the platform convention is
|
||||
// the dot, which is what the folder primitive reports for the same name.
|
||||
hidden: String(entry.name).startsWith('.'),
|
||||
})),
|
||||
truncated: listing?.truncated === true,
|
||||
files: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state behind the picker: the level on screen, the path being edited,
|
||||
* and the last failure.
|
||||
@@ -466,17 +585,25 @@ window.__ModuleLoader__.load({
|
||||
* a sequence number and only the newest may commit. It also keeps the whole
|
||||
* interaction testable without a DOM, which is the same reason the settings
|
||||
* card has a controller.
|
||||
*
|
||||
* Two listers in, one level out: see the note above {@link folderListing}
|
||||
* for why there are two and what the picker does with either.
|
||||
*/
|
||||
var DirectoryPickerModel = class {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.list - the host listing call.
|
||||
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.folders - the folder listing; browses anywhere.
|
||||
* @param {(sessionId: string, path: string, signal?: AbortSignal) => Promise<object>} [options.files] - the file listing; files and folders, confined to the workspace.
|
||||
* @param {string} [options.sessionId] - the session whose workspace the file listing is confined to.
|
||||
* @param {string} options.root - the session folder, absolute, or ''.
|
||||
* @param {string} [options.start] - the directory to open first; the root by default.
|
||||
*/
|
||||
constructor(options) {
|
||||
const root = typeof options.root === 'string' ? options.root : ''
|
||||
this.list = options.list
|
||||
this.folders = options.folders
|
||||
this.files = options.files
|
||||
this.sessionId = options.sessionId
|
||||
this.root = root
|
||||
this.listeners = new Set()
|
||||
this.request = 0
|
||||
this.state = {
|
||||
@@ -524,18 +651,56 @@ window.__ModuleLoader__.load({
|
||||
const target = String(path ?? '').trim()
|
||||
const request = (this.request += 1)
|
||||
this.update({ loading: true, error: '', draft: target })
|
||||
Promise.resolve()
|
||||
.then(() => this.list(target === '' ? undefined : target))
|
||||
.then(
|
||||
(listing) => {
|
||||
if (request !== this.request) return
|
||||
this.update({ listing, loading: false, error: '', draft: String(listing?.path ?? target) })
|
||||
},
|
||||
(reason) => {
|
||||
if (request !== this.request) return
|
||||
this.update({ loading: false, error: browseFailure(reason) })
|
||||
},
|
||||
)
|
||||
this.read(target, request)
|
||||
}
|
||||
|
||||
/**
|
||||
* One level, from whichever lister can answer for it.
|
||||
*
|
||||
* The file listing is tried first while the level is inside the session's
|
||||
* workspace, because that is the listing the extras need: it carries files
|
||||
* as well as folders. The Host confines it to the workspace, so a level
|
||||
* above the workspace root — or a deployment with no file service — falls
|
||||
* back to the folder listing, which browses anywhere and carries folders
|
||||
* only. The fallback is also the answer to a file listing that failed for
|
||||
* any other reason, so a level the file service cannot read is still
|
||||
* browsable as folders rather than reported as unreadable.
|
||||
*
|
||||
* @param {string} target - the absolute directory, or '' for the Host's home.
|
||||
* @param {number} request - the sequence number this read belongs to.
|
||||
*/
|
||||
read(target, request) {
|
||||
const files = this.files
|
||||
const inside = target === '' ? this.root !== '' : withinPath(target, this.root)
|
||||
const asFolders = () => this.folders(target === '' ? undefined : target).then((listing) => folderListing(listing, target))
|
||||
const listing =
|
||||
files === undefined || !inside
|
||||
? asFolders()
|
||||
: files(this.sessionId, target === '' ? this.root : target)
|
||||
.then((answer) => workspaceListing(answer, this.root))
|
||||
.catch(asFolders)
|
||||
Promise.resolve(listing).then(
|
||||
(level) => {
|
||||
if (request !== this.request) return
|
||||
this.update({ listing: level, loading: false, error: '', draft: level.base })
|
||||
},
|
||||
(reason) => {
|
||||
if (request !== this.request) return
|
||||
// A path that names a file in the level already on screen is not a
|
||||
// failure, it is the thing this dialog is for: the list asked for is
|
||||
// not a list, and the footer can still add what it names. That
|
||||
// matters now that a file row is one keystroke away from the field.
|
||||
const wanted = absoluteFrom(this.root, target)
|
||||
const named = (this.state.listing?.entries ?? []).some(
|
||||
(entry) => entry.directory !== true && entry.path === wanted,
|
||||
)
|
||||
if (named) {
|
||||
this.update({ loading: false, error: '' })
|
||||
return
|
||||
}
|
||||
this.update({ loading: false, error: browseFailure(reason) })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** @param {string} text - the path field's own value. */
|
||||
@@ -571,6 +736,13 @@ window.__ModuleLoader__.load({
|
||||
}
|
||||
const pickerNameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
||||
const pickerGlyphStyle = { display: 'inline-flex', flex: 'none', color: 'var(--dsw-alias-label-secondary)' }
|
||||
/** A file row the path field already names, so a click is visibly a choice. */
|
||||
const pickerChosenRowStyle = {
|
||||
...pickerRowStyle,
|
||||
// The token the Host's own directory browser uses for a chosen row, fallback
|
||||
// and all, so a pick looks the same here as it does one panel over.
|
||||
background: 'var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))',
|
||||
}
|
||||
const crumbStyle = {
|
||||
flex: 'none',
|
||||
border: 0,
|
||||
@@ -599,7 +771,13 @@ window.__ModuleLoader__.load({
|
||||
|
||||
/**
|
||||
* The directory picker: one editable path, the ancestry above it, and one
|
||||
* level of child folders.
|
||||
* level of children — folders always, and files wherever the Host will list
|
||||
* them.
|
||||
*
|
||||
* A folder row is a way down; a file row is a thing to take, so it fills the
|
||||
* path field rather than navigating, and the primary action adds what the
|
||||
* field holds. That is the one interaction the two row kinds do not share,
|
||||
* and it is why a file row is marked selected once the field names it.
|
||||
*
|
||||
* It drives no host call of its own — {@link DirectoryPickerModel} holds the
|
||||
* listing and the path, and the panel builds one per opening so each visit
|
||||
@@ -622,6 +800,9 @@ window.__ModuleLoader__.load({
|
||||
// folder: the focus's own default, and for an extra — already the root —
|
||||
// nothing worth adding.
|
||||
const declared = relativeTo(root, typed)
|
||||
// The field's own value as an absolute path, so a file row can tell whether
|
||||
// it is the one the field names.
|
||||
const chosen = absoluteFrom(root, typed)
|
||||
|
||||
const rows = entries.map((entry) =>
|
||||
h(
|
||||
@@ -630,14 +811,22 @@ window.__ModuleLoader__.load({
|
||||
key: entry.path,
|
||||
type: 'button',
|
||||
className: ROW_CLASS,
|
||||
style: pickerRowStyle,
|
||||
style: entry.directory || entry.path !== chosen ? pickerRowStyle : pickerChosenRowStyle,
|
||||
title: entry.path,
|
||||
disabled: blocked || state.loading,
|
||||
// A click walks down rather than picking: the primary action is the
|
||||
// path field's own value, so a folder is chosen by standing in it.
|
||||
onClick: () => model.open(entry.path),
|
||||
// A folder is chosen by standing in it; a file is chosen by naming
|
||||
// it, so the click puts it in the field for the footer to add. The
|
||||
// field holds an absolute path everywhere else in this dialog, and
|
||||
// the declared form is what the panel's write turns it into.
|
||||
onClick: () => (entry.directory || target === 'focus' ? model.open(entry.path) : model.setDraft(entry.path)),
|
||||
},
|
||||
h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })),
|
||||
h(
|
||||
'span',
|
||||
{ style: pickerGlyphStyle },
|
||||
entry.directory
|
||||
? h(IconFolderCloseRegular, { size: 14 })
|
||||
: h(FileTypeIcon, { path: entry.name, size: 16 }),
|
||||
),
|
||||
h('span', { style: pickerNameStyle }, entry.name),
|
||||
),
|
||||
)
|
||||
@@ -656,10 +845,7 @@ window.__ModuleLoader__.load({
|
||||
disabled: blocked || state.loading,
|
||||
onClick: () => model.open(crumb.path),
|
||||
},
|
||||
// A root crumb carries its whole path as its name; "Home" is the
|
||||
// shorter label the host's own browser gives it, and the title
|
||||
// attribute still names where it is.
|
||||
crumb.path === listing.home ? 'Home' : crumb.name,
|
||||
crumb.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -672,10 +858,19 @@ window.__ModuleLoader__.load({
|
||||
: listing === null
|
||||
? h('p', { key: 'status', style: pickerStatusStyle }, 'Type a folder to start from.')
|
||||
: entries.length === 0
|
||||
? h('p', { key: 'status', style: pickerStatusStyle }, 'No folders in here.')
|
||||
? h('p', { key: 'status', style: pickerStatusStyle }, 'Nothing in here.')
|
||||
: listing.truncated === true
|
||||
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.')
|
||||
: null
|
||||
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many entries to list; only the beginning is shown.')
|
||||
: // Above the session folder the Host's file listing stops — it is
|
||||
// confined to the workspace — so the rows become folders only.
|
||||
// Saying so is better than letting file rows silently vanish.
|
||||
target === 'extra' && listing.files !== true
|
||||
? h(
|
||||
'p',
|
||||
{ key: 'status', style: pickerStatusStyle },
|
||||
'Files are listed inside the session folder only; above it the list is folders.',
|
||||
)
|
||||
: null
|
||||
|
||||
return h(Modal, {
|
||||
open: true,
|
||||
@@ -702,8 +897,9 @@ window.__ModuleLoader__.load({
|
||||
value: typed,
|
||||
disabled: blocked,
|
||||
spellCheck: false,
|
||||
// The Host lists folders, so this box is also how a file — or a
|
||||
// folder the session will create — is named at all.
|
||||
// The listing carries the files it can reach; the box is still how a
|
||||
// path outside the session's workspace — or a folder the session
|
||||
// will create — is named at all.
|
||||
'data-modal-autofocus': true,
|
||||
placeholder: root === '' ? absoluteFrom(root, 'src/shared') : 'relative/to/the/session/folder',
|
||||
onChange: (event) => model.setDraft(event.target.value),
|
||||
@@ -718,10 +914,10 @@ window.__ModuleLoader__.load({
|
||||
{ style: hintStyle },
|
||||
target === 'focus'
|
||||
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
|
||||
: // The list holds folders only — the Host's browse primitive has no
|
||||
// file rows — so the box is where a file is named, and saying so
|
||||
// is the difference between a dead end and an instruction.
|
||||
'Relative to the session folder, or absolute. The list holds folders; type a file\u2019s name to add the file itself.',
|
||||
: // Files where the Host lists them, folders everywhere. The box is
|
||||
// still where a path the listing cannot reach gets named, so both
|
||||
// halves of that are said rather than left to be discovered.
|
||||
'Relative to the session folder, or absolute. Folders open; a file is taken as the path to add.',
|
||||
),
|
||||
),
|
||||
h(
|
||||
@@ -843,6 +1039,24 @@ window.__ModuleLoader__.load({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The file lister an opening should offer, if any.
|
||||
*
|
||||
* Files are what an extra may name, so that is the only control which offers
|
||||
* them: a focus is a folder, and a listing that could hand the picker a file
|
||||
* would only offer something that cannot be chosen. A deployment without the
|
||||
* file service gets the folder listing as before, which is why this asks
|
||||
* rather than assumes.
|
||||
*
|
||||
* @param {object} browse - the browsing face from {@link directoryBrowse}.
|
||||
* @param {string} target - 'focus' or 'extra'.
|
||||
* @returns {Function|undefined} the lister, or nothing to offer.
|
||||
*/
|
||||
function fileLister(browse, target) {
|
||||
if (target !== 'extra') return undefined
|
||||
return browse?.files?.available?.() === true ? browse.files.list : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The facet context panel: what this conversation may see, and who decides.
|
||||
*
|
||||
@@ -856,8 +1070,8 @@ window.__ModuleLoader__.load({
|
||||
* "which folder is `../shared`" is exactly the question a free-text box
|
||||
* cannot answer; the listing answers it from the Host's own directory rather
|
||||
* than from a guess about what the path was meant to name. The path field is
|
||||
* still editable inside the picker, because the Host lists folders and the
|
||||
* extras may name a file.
|
||||
* still editable inside the picker, for a path no listing reaches: a folder
|
||||
* the session will create, or a file above the session's workspace.
|
||||
*
|
||||
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
|
||||
*/
|
||||
@@ -911,7 +1125,9 @@ window.__ModuleLoader__.load({
|
||||
setPicker({
|
||||
target,
|
||||
model: new DirectoryPickerModel({
|
||||
list: browse.list,
|
||||
folders: browse.list,
|
||||
files: fileLister(browse, target),
|
||||
sessionId,
|
||||
root,
|
||||
start: isAbsolutePath(stored) ? stored : '',
|
||||
}),
|
||||
@@ -1016,7 +1232,7 @@ window.__ModuleLoader__.load({
|
||||
|
||||
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
|
||||
declared.extras.length === 0
|
||||
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
|
||||
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a single file go here.')
|
||||
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
|
||||
h(
|
||||
'div',
|
||||
@@ -1155,7 +1371,7 @@ window.__ModuleLoader__.load({
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory-browsing face the picker drives.
|
||||
* The browsing face the picker drives: two listers, because the Host has two.
|
||||
*
|
||||
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
|
||||
* the whole context button conditional on it, and the panel is still the
|
||||
@@ -1168,8 +1384,8 @@ window.__ModuleLoader__.load({
|
||||
* would freeze at whatever the first render saw, and the service may well
|
||||
* activate after this bundle does.
|
||||
*
|
||||
* @param {object} ctx - the client context to resolve the service from.
|
||||
* @returns {object} `available()` and `list()`, the two calls the panel makes.
|
||||
* @param {object} ctx - the client context to resolve the services from.
|
||||
* @returns {object} `available()`/`list()` for folders, and the same pair again under `files`.
|
||||
*/
|
||||
function directoryBrowse(ctx) {
|
||||
const workspace = () => ctx.get('uiWorkspace')
|
||||
@@ -1183,6 +1399,24 @@ window.__ModuleLoader__.load({
|
||||
}
|
||||
return service.listDirectory(path, signal)
|
||||
},
|
||||
// Folders only, anywhere on the machine. The file lister below is what
|
||||
// makes an extra selectable by name; this one is what makes it
|
||||
// reachable at all, and the two are needed together.
|
||||
files: {
|
||||
available: () => typeof ctx.get('remote.workspaceFiles')?.list === 'function',
|
||||
list: (sessionId, path, signal) => {
|
||||
const server = ctx.get('remote.workspaceFiles')
|
||||
if (server === undefined || typeof server.list !== 'function') {
|
||||
return Promise.reject(new Error('This deployment cannot list files.'))
|
||||
}
|
||||
// The Host confines this to the Session's workspace, which is why the
|
||||
// model falls back to the folder listing above the session folder.
|
||||
return server.list(sessionId, path, signal).then((result) => {
|
||||
if (result?.ok !== true) throw result?.error ?? new Error('The Host refused that listing.')
|
||||
return result.value
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,7 +1500,13 @@ window.__ModuleLoader__.load({
|
||||
absoluteFrom,
|
||||
relativeTo,
|
||||
isAbsolutePath,
|
||||
withinPath,
|
||||
joinUnder,
|
||||
trailTo,
|
||||
folderListing,
|
||||
workspaceListing,
|
||||
directoryBrowse,
|
||||
fileLister,
|
||||
}
|
||||
return module.exports
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user