mirror of
https://github.com/reactos/reactos.git
synced 2025-04-03 20:21:17 +00:00
[SHELL32]
* Move shell file operations to background threads to prevent the shell from hanging during long copies, deletes, and moves. * Improve drag and drop functionality. * Add a partial drop handler to the recycle bin. * Brought to you by Huw Campbell. CORE-3760 svn path=/trunk/; revision=61537
This commit is contained in:
parent
aa4d139a0c
commit
3ad827aa50
6 changed files with 293 additions and 69 deletions
|
@ -1037,7 +1037,6 @@ CDefaultContextMenu::DoPaste(
|
|||
}
|
||||
|
||||
SHSimulateDrop(pdrop, pda, dwKey, NULL, NULL);
|
||||
NotifyShellViewWindow(lpcmi, TRUE);
|
||||
|
||||
TRACE("CP result %x\n", hr);
|
||||
return S_OK;
|
||||
|
@ -1104,73 +1103,24 @@ CDefaultContextMenu::DoCreateLink(
|
|||
ERR("no IDropTarget Interface\n");
|
||||
return hr;
|
||||
}
|
||||
//DWORD link = DROPEFFECT_LINK;
|
||||
SHSimulateDrop(pDT, pDataObj, MK_CONTROL|MK_SHIFT, NULL, NULL);
|
||||
NotifyShellViewWindow(lpcmi, TRUE);
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT
|
||||
CDefaultContextMenu::DoDelete(
|
||||
LPCMINVOKECOMMANDINFO lpcmi)
|
||||
{
|
||||
STRRET strTemp;
|
||||
HRESULT hr = m_Dcm.psf->GetDisplayNameOf(m_Dcm.apidl[0], SHGDN_FORPARSING, &strTemp);
|
||||
if(hr != S_OK)
|
||||
HRESULT CDefaultContextMenu::DoDelete(LPCMINVOKECOMMANDINFO lpcmi) {
|
||||
TRACE("(%p) Deleting\n", this);
|
||||
|
||||
LPDATAOBJECT pDataObj;
|
||||
|
||||
if (SUCCEEDED(SHCreateDataObject(m_Dcm.pidlFolder, m_Dcm.cidl, m_Dcm.apidl, NULL, IID_PPV_ARG(IDataObject, &pDataObj))))
|
||||
{
|
||||
ERR("IShellFolder_GetDisplayNameOf failed with %x\n", hr);
|
||||
return hr;
|
||||
}
|
||||
|
||||
WCHAR wszPath[MAX_PATH];
|
||||
hr = StrRetToBufW(&strTemp, m_Dcm.apidl[0], wszPath, _countof(wszPath));
|
||||
if (hr != S_OK)
|
||||
{
|
||||
ERR("StrRetToBufW failed with %x\n", hr);
|
||||
return hr;
|
||||
}
|
||||
|
||||
/* Only keep the base path */
|
||||
LPWSTR pwszFilename = PathFindFileNameW(wszPath);
|
||||
*pwszFilename = L'\0';
|
||||
|
||||
/* Build paths list */
|
||||
LPWSTR pwszPaths = BuildPathsList(wszPath, m_Dcm.cidl, m_Dcm.apidl);
|
||||
if (!pwszPaths)
|
||||
pDataObj->AddRef();
|
||||
SHCreateThread(DoDeleteThreadProc, pDataObj, NULL, NULL);
|
||||
pDataObj->Release();
|
||||
}
|
||||
else
|
||||
return E_FAIL;
|
||||
|
||||
/* Delete them */
|
||||
SHFILEOPSTRUCTW FileOp;
|
||||
ZeroMemory(&FileOp, sizeof(FileOp));
|
||||
FileOp.hwnd = GetActiveWindow();
|
||||
FileOp.wFunc = FO_DELETE;
|
||||
FileOp.pFrom = pwszPaths;
|
||||
FileOp.fFlags = FOF_ALLOWUNDO;
|
||||
|
||||
if (SHFileOperationW(&FileOp) != 0)
|
||||
{
|
||||
ERR("SHFileOperation failed with 0x%x for %s\n", GetLastError(), debugstr_w(pwszPaths));
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
/* Get the active IShellView */
|
||||
LPSHELLBROWSER lpSB = (LPSHELLBROWSER)SendMessageW(lpcmi->hwnd, CWM_GETISHELLBROWSER, 0, 0);
|
||||
if (lpSB)
|
||||
{
|
||||
/* Is the treeview focused */
|
||||
HWND hwnd;
|
||||
if (SUCCEEDED(lpSB->GetControlWindow(FCW_TREE, &hwnd)))
|
||||
{
|
||||
/* Remove selected items from treeview */
|
||||
HTREEITEM hItem = TreeView_GetSelection(hwnd);
|
||||
if (hItem)
|
||||
(void)TreeView_DeleteItem(hwnd, hItem);
|
||||
}
|
||||
}
|
||||
NotifyShellViewWindow(lpcmi, TRUE);
|
||||
|
||||
HeapFree(GetProcessHeap(), 0, pwszPaths);
|
||||
return S_OK;
|
||||
|
||||
}
|
||||
|
|
|
@ -1419,6 +1419,27 @@ HRESULT WINAPI CFSFolder::Drop(IDataObject *pDataObject,
|
|||
{
|
||||
TRACE("(%p) object dropped, effect %u\n", this, *pdwEffect);
|
||||
|
||||
_DoDropData *data = reinterpret_cast<_DoDropData*> (HeapAlloc(GetProcessHeap(), 0, sizeof(_DoDropData)));
|
||||
data->This = this;
|
||||
// Need to maintain this class in case the window is closed or the class exists temporarily (when dropping onto a folder).
|
||||
data->This->AddRef();
|
||||
data->pDataObject = pDataObject;
|
||||
// Also keep the data object in case it gets freed elsewhere.
|
||||
data->pDataObject->AddRef();
|
||||
data->dwKeyState = dwKeyState;
|
||||
data->pt = pt;
|
||||
// Need to dereference as pdweffect is freed.
|
||||
data->pdwEffect = *pdwEffect;
|
||||
|
||||
SHCreateThread(reinterpret_cast<LPTHREAD_START_ROUTINE> (CFSFolder::_DoDropThreadProc), reinterpret_cast<void *> (data), NULL, NULL);
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT WINAPI CFSFolder::_DoDrop(IDataObject *pDataObject,
|
||||
DWORD dwKeyState, POINTL pt, DWORD *pdwEffect)
|
||||
{
|
||||
TRACE("(%p) performing drop, effect %u\n", this, *pdwEffect);
|
||||
|
||||
HRESULT hr;
|
||||
bool bCopy = TRUE;
|
||||
bool bLinking = FALSE;
|
||||
|
@ -1451,9 +1472,9 @@ HRESULT WINAPI CFSFolder::Drop(IDataObject *pDataObject,
|
|||
if (pdwEffect)
|
||||
{
|
||||
TRACE("Current drop effect flag %i\n", *pdwEffect);
|
||||
if (*pdwEffect & DROPEFFECT_MOVE)
|
||||
if ((*pdwEffect & DROPEFFECT_MOVE) == DROPEFFECT_MOVE)
|
||||
bCopy = FALSE;
|
||||
if (*pdwEffect & DROPEFFECT_LINK)
|
||||
if ((*pdwEffect & DROPEFFECT_LINK) == DROPEFFECT_LINK)
|
||||
bLinking = TRUE;
|
||||
}
|
||||
|
||||
|
@ -1631,5 +1652,12 @@ HRESULT WINAPI CFSFolder::Drop(IDataObject *pDataObject,
|
|||
}
|
||||
|
||||
DWORD CFSFolder::_DoDropThreadProc(LPVOID lpParameter) {
|
||||
_DoDropData *data = reinterpret_cast<_DoDropData*>(lpParameter);
|
||||
data->This->_DoDrop(data->pDataObject, data->dwKeyState, data->pt, &data->pdwEffect);
|
||||
//Release the CFSFolder and data object holds in the copying thread.
|
||||
data->pDataObject->Release();
|
||||
data->This->Release();
|
||||
//Release the parameter from the heap.
|
||||
HeapFree(GetProcessHeap(), 0, data);
|
||||
return 0;
|
||||
}
|
|
@ -46,7 +46,8 @@ class CFSFolder :
|
|||
BOOL QueryDrop (DWORD dwKeyState, LPDWORD pdwEffect);
|
||||
void SF_RegisterClipFmt();
|
||||
BOOL GetUniqueFileName(LPWSTR pwszBasePath, LPCWSTR pwszExt, LPWSTR pwszTarget, BOOL bShortcut);
|
||||
DWORD _DoDropThreadProc(LPVOID lpParameter);
|
||||
static DWORD _DoDropThreadProc(LPVOID lpParameter);
|
||||
virtual HRESULT WINAPI _DoDrop(IDataObject *pDataObject, DWORD dwKeyState, POINTL pt, DWORD *pdwEffect);
|
||||
|
||||
public:
|
||||
CFSFolder();
|
||||
|
@ -116,4 +117,12 @@ class CFSFolder :
|
|||
END_COM_MAP()
|
||||
};
|
||||
|
||||
struct _DoDropData {
|
||||
CFSFolder *This;
|
||||
IDataObject *pDataObject;
|
||||
DWORD dwKeyState;
|
||||
POINTL pt;
|
||||
DWORD pdwEffect;
|
||||
};
|
||||
|
||||
#endif // _CFSFOLDER_H_
|
||||
|
|
|
@ -429,10 +429,24 @@ static HRESULT WINAPI CRecycleBinItemContextMenuConstructor(REFIID riid, LPCITEM
|
|||
return S_OK;
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* registers clipboardformat once
|
||||
*/
|
||||
void CRecycleBin::SF_RegisterClipFmt()
|
||||
{
|
||||
TRACE ("(%p)\n", this);
|
||||
|
||||
if (!cfShellIDList)
|
||||
cfShellIDList = RegisterClipboardFormatW(CFSTR_SHELLIDLIST);
|
||||
}
|
||||
|
||||
CRecycleBin::CRecycleBin()
|
||||
{
|
||||
pidl = NULL;
|
||||
iIdEmpty = 0;
|
||||
cfShellIDList = 0;
|
||||
SF_RegisterClipFmt();
|
||||
fAcceptFmt = FALSE;
|
||||
}
|
||||
|
||||
CRecycleBin::~CRecycleBin()
|
||||
|
@ -556,8 +570,7 @@ HRESULT WINAPI CRecycleBin::CreateViewObject(HWND hwndOwner, REFIID riid, void *
|
|||
|
||||
if (IsEqualIID (riid, IID_IDropTarget))
|
||||
{
|
||||
WARN ("IDropTarget not implemented\n");
|
||||
hr = E_NOTIMPL;
|
||||
hr = this->QueryInterface (IID_IDropTarget, ppv);
|
||||
}
|
||||
else if (IsEqualIID (riid, IID_IContextMenu) || IsEqualIID (riid, IID_IContextMenu2))
|
||||
{
|
||||
|
@ -605,7 +618,7 @@ HRESULT WINAPI CRecycleBin::GetUIObjectOf(HWND hwndOwner, UINT cidl, LPCITEMIDLI
|
|||
{
|
||||
hr = CRecycleBinItemContextMenuConstructor(riid, apidl[0], (void **)&pObj);
|
||||
}
|
||||
else if (IsEqualIID (riid, IID_IDropTarget) && (cidl >= 1))
|
||||
else if (IsEqualIID (riid, IID_IDropTarget) && (cidl == 1))
|
||||
{
|
||||
hr = this->QueryInterface(IID_IDropTarget, (LPVOID *) & pObj);
|
||||
}
|
||||
|
@ -1372,3 +1385,214 @@ EXTERN_C HRESULT WINAPI SHUpdateRecycleBinIcon(void)
|
|||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
/****************************************************************************
|
||||
* IDropTarget implementation
|
||||
*/
|
||||
BOOL CRecycleBin::QueryDrop(DWORD dwKeyState, LPDWORD pdwEffect)
|
||||
{
|
||||
/* TODO on shift we should delete, we should update the cursor manager to show this. */
|
||||
|
||||
DWORD dwEffect = DROPEFFECT_COPY;
|
||||
|
||||
*pdwEffect = DROPEFFECT_NONE;
|
||||
|
||||
if (fAcceptFmt) { /* Does our interpretation of the keystate ... */
|
||||
*pdwEffect = KeyStateToDropEffect (dwKeyState);
|
||||
|
||||
if (*pdwEffect == DROPEFFECT_NONE)
|
||||
*pdwEffect = dwEffect;
|
||||
|
||||
/* ... matches the desired effect ? */
|
||||
if (dwEffect & *pdwEffect) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
HRESULT WINAPI CRecycleBin::DragEnter(IDataObject *pDataObject,
|
||||
DWORD dwKeyState, POINTL pt, DWORD *pdwEffect)
|
||||
{
|
||||
FIXME("Recycle bin drag over (%p)\n", this);
|
||||
/* The recycle bin accepts pretty much everything, and sets a CSIDL flag. */
|
||||
fAcceptFmt = TRUE;
|
||||
|
||||
QueryDrop(dwKeyState, pdwEffect);
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT WINAPI CRecycleBin::DragOver(DWORD dwKeyState, POINTL pt,
|
||||
DWORD *pdwEffect)
|
||||
{
|
||||
TRACE("(%p)\n", this);
|
||||
|
||||
if (!pdwEffect)
|
||||
return E_INVALIDARG;
|
||||
|
||||
QueryDrop(dwKeyState, pdwEffect);
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT WINAPI CRecycleBin::DragLeave()
|
||||
{
|
||||
TRACE("(%p)\n", this);
|
||||
|
||||
fAcceptFmt = FALSE;
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT WINAPI CRecycleBin::Drop(IDataObject *pDataObject,
|
||||
DWORD dwKeyState, POINTL pt, DWORD *pdwEffect)
|
||||
{
|
||||
FIXME("(%p) object dropped on recycle bin, effect %u\n", this, *pdwEffect);
|
||||
|
||||
/* TODO: pdwEffect should be read and make the drop object be permanently deleted in the move case (shift held) */
|
||||
|
||||
FORMATETC fmt;
|
||||
TRACE("(%p)->(DataObject=%p)\n", this, pDataObject);
|
||||
InitFormatEtc (fmt, cfShellIDList, TYMED_HGLOBAL);
|
||||
|
||||
/* Handle cfShellIDList Drop objects here, otherwise send the approriate message to other software */
|
||||
if (SUCCEEDED(pDataObject->QueryGetData(&fmt))) {
|
||||
pDataObject->AddRef();
|
||||
SHCreateThread(DoDeleteThreadProc, pDataObject, NULL, NULL);
|
||||
}
|
||||
else
|
||||
{
|
||||
/*
|
||||
* TODO call SetData on the data object with format CFSTR_TARGETCLSID
|
||||
* set to the Recycle Bin's class identifier CLSID_RecycleBin.
|
||||
*/
|
||||
}
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
DWORD WINAPI DoDeleteThreadProc(LPVOID lpParameter)
|
||||
{
|
||||
IDataObject *pda = (IDataObject*) lpParameter;
|
||||
DoDeleteDataObject(pda);
|
||||
//Release the data object
|
||||
pda->Release();
|
||||
return 0;
|
||||
}
|
||||
|
||||
HRESULT WINAPI DoDeleteDataObject(IDataObject *pda)
|
||||
{
|
||||
TRACE("performing delete");
|
||||
HRESULT hr;
|
||||
|
||||
STGMEDIUM medium;
|
||||
FORMATETC formatetc;
|
||||
InitFormatEtc(formatetc, RegisterClipboardFormatW(CFSTR_SHELLIDLIST), TYMED_HGLOBAL);
|
||||
hr = pda->GetData(&formatetc, &medium);
|
||||
if (FAILED(hr))
|
||||
return hr;
|
||||
|
||||
/* lock the handle */
|
||||
LPIDA lpcida = (LPIDA)GlobalLock(medium.hGlobal);
|
||||
if (!lpcida)
|
||||
{
|
||||
ReleaseStgMedium(&medium);
|
||||
return E_FAIL;
|
||||
}
|
||||
|
||||
/* convert the data into pidl */
|
||||
LPITEMIDLIST pidl;
|
||||
LPITEMIDLIST *apidl = _ILCopyCidaToaPidl(&pidl, lpcida);
|
||||
if (!apidl)
|
||||
{
|
||||
ReleaseStgMedium(&medium);
|
||||
return E_FAIL;
|
||||
}
|
||||
|
||||
CComPtr<IShellFolder> psfDesktop;
|
||||
CComPtr<IShellFolder> psfFrom = NULL;
|
||||
|
||||
/* Grab the desktop shell folder */
|
||||
hr = SHGetDesktopFolder(&psfDesktop);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
ERR("SHGetDesktopFolder failed\n");
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
return E_FAIL;
|
||||
}
|
||||
|
||||
/* Find source folder, this is where the clipboard data was copied from */
|
||||
if (_ILIsDesktop(pidl))
|
||||
{
|
||||
psfFrom = psfDesktop;
|
||||
}
|
||||
else
|
||||
{
|
||||
hr = psfDesktop->BindToObject(pidl, NULL, IID_IShellFolder, (LPVOID*)&psfFrom);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
ERR("no IShellFolder\n");
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
return E_FAIL;
|
||||
}
|
||||
}
|
||||
|
||||
STRRET strTemp;
|
||||
hr = psfFrom->GetDisplayNameOf(apidl[0], SHGDN_FORPARSING, &strTemp);
|
||||
if (FAILED(hr))
|
||||
{
|
||||
ERR("IShellFolder_GetDisplayNameOf failed with %x\n", hr);
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
return hr;
|
||||
}
|
||||
|
||||
WCHAR wszPath[MAX_PATH];
|
||||
hr = StrRetToBufW(&strTemp, apidl[0], wszPath, _countof(wszPath));
|
||||
if (FAILED(hr))
|
||||
{
|
||||
ERR("StrRetToBufW failed with %x\n", hr);
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
return hr;
|
||||
}
|
||||
|
||||
/* Only keep the base path */
|
||||
LPWSTR pwszFilename = PathFindFileNameW(wszPath);
|
||||
*pwszFilename = L'\0';
|
||||
|
||||
/* Build paths list */
|
||||
LPWSTR pwszPaths = BuildPathsList(wszPath, lpcida->cidl, (LPCITEMIDLIST*) apidl);
|
||||
if (!pwszPaths)
|
||||
{
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
return E_FAIL;
|
||||
}
|
||||
|
||||
/* Delete them */
|
||||
SHFILEOPSTRUCTW FileOp;
|
||||
ZeroMemory(&FileOp, sizeof(FileOp));
|
||||
FileOp.wFunc = FO_DELETE;
|
||||
FileOp.pFrom = pwszPaths;
|
||||
FileOp.fFlags = FOF_ALLOWUNDO;
|
||||
|
||||
if (SHFileOperationW(&FileOp) != 0)
|
||||
{
|
||||
ERR("SHFileOperation failed with 0x%x for %s\n", GetLastError(), debugstr_w(pwszPaths));
|
||||
hr = E_FAIL;
|
||||
}
|
||||
|
||||
HeapFree(GetProcessHeap(), 0, pwszPaths);
|
||||
SHFree(pidl);
|
||||
_ILFreeaPidl(apidl, lpcida->cidl);
|
||||
ReleaseStgMedium(&medium);
|
||||
|
||||
return hr;
|
||||
}
|
|
@ -22,6 +22,9 @@
|
|||
#ifndef _SHFLDR_RECYCLEBIN_H_
|
||||
#define _SHFLDR_RECYCLEBIN_H_
|
||||
|
||||
DWORD WINAPI DoDeleteThreadProc(LPVOID lpParameter);
|
||||
HRESULT WINAPI DoDeleteDataObject(IDataObject *pda);
|
||||
|
||||
class CRecycleBin :
|
||||
public CComCoClass<CRecycleBin, &CLSID_RecycleBin>,
|
||||
public CComObjectRootEx<CComMultiThreadModelNoCS>,
|
||||
|
@ -29,11 +32,16 @@ class CRecycleBin :
|
|||
public IPersistFolder2,
|
||||
public IContextMenu,
|
||||
public IShellPropSheetExt,
|
||||
public IDropTarget,
|
||||
public IShellExtInit
|
||||
{
|
||||
private:
|
||||
LPITEMIDLIST pidl;
|
||||
INT iIdEmpty;
|
||||
UINT cfShellIDList;
|
||||
void SF_RegisterClipFmt();
|
||||
BOOL fAcceptFmt; /* flag for pending Drop */
|
||||
BOOL QueryDrop (DWORD dwKeyState, LPDWORD pdwEffect);
|
||||
|
||||
public:
|
||||
CRecycleBin();
|
||||
|
@ -75,6 +83,12 @@ class CRecycleBin :
|
|||
// IShellPropSheetExt
|
||||
virtual HRESULT WINAPI AddPages(LPFNSVADDPROPSHEETPAGE pfnAddPage, LPARAM lParam);
|
||||
virtual HRESULT WINAPI ReplacePage(EXPPS uPageID, LPFNSVADDPROPSHEETPAGE pfnReplaceWith, LPARAM lParam);
|
||||
|
||||
// IDropTarget
|
||||
virtual HRESULT WINAPI DragEnter(IDataObject *pDataObject, DWORD dwKeyState, POINTL pt, DWORD *pdwEffect);
|
||||
virtual HRESULT WINAPI DragOver(DWORD dwKeyState, POINTL pt, DWORD *pdwEffect);
|
||||
virtual HRESULT WINAPI DragLeave();
|
||||
virtual HRESULT WINAPI Drop(IDataObject *pDataObject, DWORD dwKeyState, POINTL pt, DWORD *pdwEffect);
|
||||
|
||||
// IShellExtInit
|
||||
virtual HRESULT STDMETHODCALLTYPE Initialize(LPCITEMIDLIST pidlFolder, IDataObject *pdtobj, HKEY hkeyProgID);
|
||||
|
@ -91,6 +105,7 @@ class CRecycleBin :
|
|||
COM_INTERFACE_ENTRY_IID(IID_IShellFolder2, IShellFolder2)
|
||||
COM_INTERFACE_ENTRY_IID(IID_IContextMenu, IContextMenu)
|
||||
COM_INTERFACE_ENTRY_IID(IID_IShellPropSheetExt, IShellPropSheetExt)
|
||||
COM_INTERFACE_ENTRY_IID(IID_IDropTarget, IDropTarget)
|
||||
COM_INTERFACE_ENTRY_IID(IID_IShellExtInit, IShellExtInit)
|
||||
END_COM_MAP()
|
||||
};
|
||||
|
|
|
@ -1691,8 +1691,6 @@ LRESULT CDefView::OnNotify(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL &bHandl
|
|||
{
|
||||
DWORD dwEffect2;
|
||||
DoDragDrop(pda, pds, dwEffect, &dwEffect2);
|
||||
if ((dwEffect2 & DROPEFFECT_MOVE) == DROPEFFECT_MOVE)
|
||||
this->Refresh();
|
||||
}
|
||||
pda->Release();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue