diff --git a/docs/en_US/images/preferences_misc_file_downloads.png b/docs/en_US/images/preferences_misc_file_downloads.png new file mode 100644 index 00000000000..fe8f7f98149 Binary files /dev/null and b/docs/en_US/images/preferences_misc_file_downloads.png differ diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index fe298616915..5aea1edec18 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -273,6 +273,24 @@ The Miscellaneous Node Expand the *Miscellaneous* node to specify miscellaneous display preferences. +.. image:: images/preferences_misc_file_downloads.png + :alt: Preferences dialog file downloads section + :align: center + +Use the fields on the *File Downloads* panel to manage file downloads related preferences. + +* When the *Automatically open downloaded files?* switch is set to *True* + the downloaded file will automatically open in the system's default + application associated with that file type. + +* When the *Prompt for the download location?* switch is set to *True* + a prompt will appear after clicking the download button, allowing you + to choose the download location. + +**Note:** File Downloads related settings are applicable and visible only in desktop mode. + +Use the fields on the *User Interface* panel to set the user interface related preferences. + .. image:: images/preferences_misc_user_interface.png :alt: Preferences dialog user interface section :align: center diff --git a/runtime/package.json b/runtime/package.json index 6a02fa45c92..042fc6bd3ec 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -18,6 +18,7 @@ "dependencies": { "axios": "^1.8.1", "electron-context-menu": "^4.0.5", + "electron-dl": "^4.0.0", "electron-store": "^10.0.0" } } diff --git a/runtime/src/js/pgadmin.js b/runtime/src/js/pgadmin.js index 4000c7e5b93..d981bc0f345 100644 --- a/runtime/src/js/pgadmin.js +++ b/runtime/src/js/pgadmin.js @@ -16,6 +16,7 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { setupMenu } from './menu.js'; import contextMenu from 'electron-context-menu'; +import { CancelError, download } from 'electron-dl'; const configStore = new Store({ defaults: { @@ -126,6 +127,28 @@ function reloadApp() { currWin.webContents.reload(); } +async function desktopFileDownload(payload) { + const currWin = BrowserWindow.getFocusedWindow(); + try { + await download(currWin, payload.downloadUrl, { + filename: payload.fileName, + saveAs: payload.prompt_for_download_location, + onProgress: (progress) => { + currWin.webContents.send('download-progress', progress); + }, + onCompleted: (item) => { + currWin.webContents.send('download-complete', item); + if (payload.automatically_open_downloaded_file) + shell.openPath(item.path); + }, + }); + } catch (error) { + if (!(error instanceof CancelError)) { + misc.writeServerLog(error); + } + } +} + // This functions is used to start the pgAdmin4 server by spawning a // separate process. function startDesktopMode() { @@ -369,6 +392,7 @@ ipcMain.on('log', (text) => ()=>{ misc.writeServerLog(text); }); ipcMain.on('reloadApp', reloadApp); +ipcMain.on('onFileDownload', (_, payload) => desktopFileDownload(payload)); ipcMain.handle('checkPortAvailable', async (_e, fixedPort)=>{ try { await misc.getAvailablePort(fixedPort); diff --git a/runtime/src/js/pgadmin_preload.js b/runtime/src/js/pgadmin_preload.js index 933de0237af..a2ebe98a2f3 100644 --- a/runtime/src/js/pgadmin_preload.js +++ b/runtime/src/js/pgadmin_preload.js @@ -24,4 +24,5 @@ contextBridge.exposeInMainWorld('electronUI', { showSaveDialog: (options) => ipcRenderer.invoke('showSaveDialog', options), log: (text)=> ipcRenderer.send('log', text), reloadApp: ()=>{ipcRenderer.send('reloadApp');}, + onFileDownload: (payload) => ipcRenderer.send('onFileDownload', payload), }); \ No newline at end of file diff --git a/runtime/yarn.lock b/runtime/yarn.lock index 72aed675b3d..5e0d0b5309d 100644 --- a/runtime/yarn.lock +++ b/runtime/yarn.lock @@ -1550,6 +1550,7 @@ __metadata: axios: ^1.8.1 electron: 34.3.0 electron-context-menu: ^4.0.5 + electron-dl: ^4.0.0 electron-store: ^10.0.0 eslint: ^9.21.0 languageName: unknown diff --git a/web/pgadmin/dashboard/static/js/Dashboard.jsx b/web/pgadmin/dashboard/static/js/Dashboard.jsx index 09f318553f7..cac96561248 100644 --- a/web/pgadmin/dashboard/static/js/Dashboard.jsx +++ b/web/pgadmin/dashboard/static/js/Dashboard.jsx @@ -40,7 +40,7 @@ import Replication from './Replication'; import { getExpandCell } from '../../../static/js/components/PgReactTableStyled'; import CodeMirror from '../../../static/js/components/ReactCodeMirror'; import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; -import { getBrowser } from '../../../static/js/utils'; +import { downloadFile } from '../../../static/js/utils'; import RefreshButton from './components/RefreshButtons'; function parseData(data) { @@ -453,22 +453,7 @@ function Dashboard({ let fileName = 'data-' + new Date().getTime() + extension; try { - let respBlob = new Blob([respData], {type : 'text/'+type}), - urlCreator = window.URL || window.webkitURL, - download_url = urlCreator.createObjectURL(respBlob), - link = document.createElement('a'); - - document.body.appendChild(link); - - if (getBrowser() == 'IE' && window.navigator.msSaveBlob) { - // IE10: (has Blob, but not a[download] or URL) - window.navigator.msSaveBlob(respBlob, fileName); - } else { - link.setAttribute('href', download_url); - link.setAttribute('download', fileName); - link.click(); - } - document.body.removeChild(link); + downloadFile(respData, fileName, `text/${type}`); } catch { setSsMsg(gettext('Failed to download the logs.')); } diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index 33c2db31e4c..e0c3f5d87e5 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -15,7 +15,8 @@ from pgadmin.user_login_check import pga_login_required from pathlib import Path from pgadmin.utils import PgAdminModule, get_binary_path_versions -from pgadmin.utils.constants import PREF_LABEL_USER_INTERFACE +from pgadmin.utils.constants import PREF_LABEL_USER_INTERFACE, \ + PREF_LABEL_FILE_DOWNLOADS from pgadmin.utils.csrf import pgCSRFProtect from pgadmin.utils.session import cleanup_session_files from pgadmin.misc.themes import get_all_themes @@ -120,6 +121,32 @@ def register_preferences(self): ) ) + if not config.SERVER_MODE: + self.preference.register( + 'file_downloads', 'automatically_open_downloaded_file', + gettext("Automatically open downloaded file?"), + 'boolean', False, + category_label=PREF_LABEL_FILE_DOWNLOADS, + help_str=gettext( + '''This setting is applicable and visible only in + desktop mode. When set to True, the downloaded file + will automatically open in the system's default + application associated with that file type.''' + ) + ) + self.preference.register( + 'file_downloads', 'prompt_for_download_location', + gettext("Prompt for the download location?"), + 'boolean', True, + category_label=PREF_LABEL_FILE_DOWNLOADS, + help_str=gettext( + 'This setting is applicable and visible only ' + 'in desktop mode. When set to True, a prompt ' + 'will appear after clicking the download button, ' + 'allowing you to choose the download location' + ) + ) + def get_exposed_url_endpoints(self): """ Returns: diff --git a/web/pgadmin/static/js/Explain/svg_download.js b/web/pgadmin/static/js/Explain/svg_download.js index d5d0c5a475b..7fdb30ee164 100644 --- a/web/pgadmin/static/js/Explain/svg_download.js +++ b/web/pgadmin/static/js/Explain/svg_download.js @@ -7,6 +7,7 @@ // ////////////////////////////////////////////////////////////// import getApiInstance from '../api_instance'; +import { downloadFile } from '../utils'; function convertImageURLtoDataURI(api, image) { return new Promise(function(resolve, reject) { @@ -42,13 +43,6 @@ export function downloadSvg(svg, svgName) { } Promise.all(image_promises).then(function() { - let blob = new Blob([svgElement.outerHTML], {type: 'image/svg+xml'}); - let svgURL = (window.URL || window.webkitURL).createObjectURL(blob); - let newElement = document.createElement('a'); - newElement.href = svgURL; - newElement.setAttribute('download', svgName); - document.body.appendChild(newElement); - newElement.click(); - document.body.removeChild(newElement); + downloadFile(svgElement.outerHTML, svgName, 'image/svg+xml'); }); } diff --git a/web/pgadmin/static/js/utils.js b/web/pgadmin/static/js/utils.js index 139ac963c66..dae0d11278b 100644 --- a/web/pgadmin/static/js/utils.js +++ b/web/pgadmin/static/js/utils.js @@ -369,14 +369,16 @@ export function checkTrojanSource(content, isPasteEvent) { } } -export function downloadBlob(blob, fileName) { - if (getBrowser() == 'IE' && window.navigator.msSaveBlob) { +export async function downloadBlob(blob, fileName) { + const {automatically_open_downloaded_file, prompt_for_download_location} = usePreferences.getState().getPreferencesForModule('misc'); + const urlCreator = window.URL || window.webkitURL; + const downloadUrl = urlCreator.createObjectURL(blob); + if (getBrowser().name == 'IE' && window.navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL) window.navigator.msSaveBlob(blob, fileName); + } else if (getBrowser().name == 'Electron') { + await window.electronUI.onFileDownload({downloadUrl, fileName, automatically_open_downloaded_file, prompt_for_download_location}); } else { - const urlCreator = window.URL || window.webkitURL; - const downloadUrl = urlCreator.createObjectURL(blob); - const link = document.createElement('a'); link.setAttribute('href', downloadUrl); link.setAttribute('download', fileName); @@ -388,6 +390,19 @@ export function downloadBlob(blob, fileName) { } } +export async function downloadUrlData(downloadUrl, fileName) { + const {automatically_open_downloaded_file, prompt_for_download_location} = usePreferences.getState().getPreferencesForModule('misc'); + if (getBrowser().name == 'Electron') { + window.electronUI.onFileDownload({downloadUrl, fileName, automatically_open_downloaded_file, prompt_for_download_location}); + } else { + let link = document.createElement('a'); + link.setAttribute('href', downloadUrl); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + } +} + export function downloadFile(textData, fileName, fileType) { const respBlob = new Blob([textData], {type : fileType}); downloadBlob(respBlob, fileName); diff --git a/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx b/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx index 5154cf44809..9b02188846d 100644 --- a/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx +++ b/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx @@ -37,6 +37,7 @@ import pgAdmin from 'sources/pgadmin'; import { styled } from '@mui/material/styles'; import BeforeUnload from './BeforeUnload'; import { isMac } from '../../../../../../static/js/keyboard_shortcuts'; +import { downloadUrlData } from '../../../../../../static/js/utils'; /* Custom react-diagram action for keyboard events */ export class KeyboardShortcutAction extends Action { @@ -760,11 +761,7 @@ export default class ERDTool extends React.Component { } toPng(this.canvasEle, {width, height}) .then((dataUrl)=>{ - let link = document.createElement('a'); - link.setAttribute('href', dataUrl); - link.setAttribute('download', this.getCurrentProjectName() + '.png'); - link.click(); - link.remove(); + downloadUrlData(dataUrl, `${this.getCurrentProjectName()}.png`); }).catch((err)=>{ console.error(err); let msg = gettext('Unknown error. Check console logs'); diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx index af44207efd0..b4d441d4d39 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx @@ -26,7 +26,7 @@ import { LineChart, BarChart, PieChart, DATA_POINT_STYLE, DATA_POINT_SIZE, LightenDarkenColor} from 'sources/chartjs'; import { QueryToolEventsContext, QueryToolContext } from '../QueryToolComponent'; import { QUERY_TOOL_EVENTS, PANELS } from '../QueryToolConstants'; -import { getChartColor } from '../../../../../../static/js/utils'; +import { downloadUrlData, getChartColor } from '../../../../../../static/js/utils'; const StyledBox = styled(Box)(({theme}) => ({ width: '100%', @@ -377,11 +377,10 @@ export function GraphVisualiser({initColumns}) { }; // Download button callback - const onDownloadGraph = ()=> { - let a = document.createElement('a'); - a.href = chartObjRef.current.toBase64Image(); - a.download = 'graph_visualiser-' + new Date().getTime() + '.png'; - a.click(); + const onDownloadGraph = async ()=> { + let downloadUrl = chartObjRef.current.toBase64Image(), + fileName = 'graph_visualiser-' + new Date().getTime() + '.png'; + downloadUrlData(downloadUrl, fileName); }; // This plugin is used to set the background color of the canvas. Very useful diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 15ad95598b9..aea5a06e2bb 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -22,7 +22,7 @@ import { LayoutDockerContext } from '../../../../../../static/js/helpers/Layout' import { GeometryViewer } from './GeometryViewer'; import Explain from '../../../../../../static/js/Explain'; import { QuerySources } from './QueryHistory'; -import { getBrowser } from '../../../../../../static/js/utils'; +import { downloadFile } from '../../../../../../static/js/utils'; import CopyData from '../QueryToolDataGrid/CopyData'; import moment from 'moment'; import ConfirmSaveContent from '../../../../../../static/js/Dialogs/ConfirmSaveContent'; @@ -478,22 +478,7 @@ export class ResultSetUtils { } } else { this.hasQueryCommitted = false; - let respBlob = new Blob([respData], {type : 'text/csv'}), - urlCreator = window.URL || window.webkitURL, - download_url = urlCreator.createObjectURL(respBlob), - link = document.createElement('a'); - - document.body.appendChild(link); - - if (getBrowser() == 'IE' && window.navigator.msSaveBlob) { - // IE10+ : (has Blob, but not a[download] or URL) - window.navigator.msSaveBlob(respBlob, fileName); - } else { - link.setAttribute('href', download_url); - link.setAttribute('download', fileName); - link.click(); - } - document.body.removeChild(link); + downloadFile(respData, fileName, 'text/csv'); } this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END); } catch (error) { diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index fa34bd6db90..5c91968bb7a 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -29,6 +29,7 @@ PREF_LABEL_REFRESH_RATES = gettext('Refresh rates') PREF_LABEL_GRAPH_VISUALISER = gettext('Graph Visualiser') PREF_LABEL_USER_INTERFACE = gettext('User Interface') +PREF_LABEL_FILE_DOWNLOADS = gettext('File Downloads') PGADMIN_STRING_SEPARATOR = '_$PGADMIN$_' PGADMIN_NODE = 'pgadmin.node.%s'