Skip to content

Commit 126e1fb

Browse files
Enabled large file downloads for desktop users within the query tool. #3369
1 parent ebf4963 commit 126e1fb

File tree

17 files changed

+354
-110
lines changed

17 files changed

+354
-110
lines changed

docs/en_US/release_notes_9_4.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Bundled PostgreSQL Utilities
2020
New features
2121
************
2222

23+
| `Issue #3369 <https://github.com/pgadmin-org/pgadmin4/issues/3369>`_ - Enabled large file downloads for desktop users within the query tool.
2324
| `Issue #8583 <https://github.com/pgadmin-org/pgadmin4/issues/8583>`_ - Add all missing options to the Import/Export Data functionality, and update the syntax of the COPY command to align with the latest standards.
2425
| `Issue #8681 <https://github.com/pgadmin-org/pgadmin4/issues/8681>`_ - Add support for exporting table data based on a custom query.
2526

runtime/.eslintrc.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//////////////////////////////////////////////////////////////
99
import globals from 'globals';
1010
import js from '@eslint/js';
11+
import unusedImports from 'eslint-plugin-unused-imports';
1112

1213
export default [
1314
js.configs.recommended,
@@ -34,6 +35,9 @@ export default [
3435
'platform': 'readonly',
3536
},
3637
},
38+
'plugins': {
39+
'unused-imports': unusedImports,
40+
},
3741
'rules': {
3842
'indent': [
3943
'error',
@@ -55,6 +59,17 @@ export default [
5559
'no-console': ['error', { allow: ['warn', 'error'] }],
5660
// We need to exclude below for RegEx case
5761
'no-useless-escape': 0,
62+
'no-unused-vars': 'off',
63+
'unused-imports/no-unused-imports': 'error',
64+
'unused-imports/no-unused-vars': [
65+
'warn',
66+
{
67+
'vars': 'all',
68+
'varsIgnorePattern': '^_',
69+
'args': 'after-used',
70+
'argsIgnorePattern': '^_',
71+
},
72+
],
5873
},
5974
},
6075
];

runtime/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
"packageManager": "yarn@3.8.7",
1414
"devDependencies": {
1515
"electron": "36.2.0",
16-
"eslint": "^9.26.0"
16+
"eslint": "^9.26.0",
17+
"eslint-plugin-unused-imports": "^4.1.4"
1718
},
1819
"dependencies": {
1920
"axios": "^1.9.0",
2021
"electron-context-menu": "^4.0.5",
21-
"electron-dl": "^4.0.0",
22-
"electron-store": "^10.0.0"
22+
"electron-store": "^10.0.1"
2323
}
2424
}

runtime/src/js/downloader.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/////////////////////////////////////////////////////////////
2+
//
3+
// pgAdmin 4 - PostgreSQL Tools
4+
//
5+
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
6+
// This software is released under the PostgreSQL Licence
7+
//
8+
//////////////////////////////////////////////////////////////
9+
10+
import { app, ipcMain, dialog, BrowserWindow, shell } from 'electron';
11+
import fs from 'fs';
12+
import path from 'path';
13+
import { setBadge, clearBadge, clearProgress, setProgress } from './progress.js';
14+
import { writeServerLog } from './misc.js';
15+
16+
class DownloadItem {
17+
constructor(filePath, onUpdate, onRemove) {
18+
this.filePath = filePath;
19+
this.currentLoaded = 0;
20+
this.total = null;
21+
this.stream = fs.createWriteStream(filePath);;
22+
this.onUpdate = onUpdate;
23+
this.onRemove = onRemove;
24+
}
25+
write(chunk) {
26+
this.stream.write(chunk);
27+
this.currentLoaded += chunk.length;
28+
this.onUpdate?.();
29+
}
30+
setTotal(total) {
31+
this.total = total;
32+
}
33+
remove() {
34+
this.stream.end();
35+
this.onRemove?.();
36+
}
37+
}
38+
39+
const downloadQueue = {};
40+
41+
function updateProgress(callerWindow) {
42+
let count = Object.keys(downloadQueue).length;
43+
if (count === 0) {
44+
clearBadge();
45+
clearProgress.call(callerWindow);
46+
return;
47+
}
48+
setBadge(Object.keys(downloadQueue).length);
49+
let progress = 0;
50+
if(Object.values(downloadQueue).some((item) => item.total === null)) {
51+
// If any of the items in the queue does not have a total, we cannot calculate progress
52+
// so we return 2 to indicate that the progress is indeterminate.
53+
progress = 2;
54+
} else {
55+
const total = Object.values(downloadQueue).reduce((acc, item) => {
56+
if (item.total) {
57+
return acc + item.currentLoaded / item.total;
58+
}
59+
return acc + item.currentLoaded;
60+
}, 0);
61+
progress = total / Object.keys(downloadQueue).length;
62+
}
63+
setProgress.call(callerWindow, progress);
64+
}
65+
66+
export function setupDownloader() {
67+
// Listen for the renderer's request to show the open dialog
68+
ipcMain.handle('get-download-path', async (event, options, prompt=true) => {
69+
try {
70+
let filePath = path.join(app.getPath('downloads'), options.defaultPath);
71+
const callerWindow = BrowserWindow.fromWebContents(event.sender);
72+
// prompt is true when the user has set the preference to prompt for download location
73+
if(prompt) {
74+
const result = await dialog.showSaveDialog(callerWindow, {
75+
title: 'Save File',
76+
...options,
77+
});
78+
79+
if (result.canceled) {
80+
return;
81+
}
82+
filePath = result.filePath;
83+
}
84+
85+
downloadQueue[filePath] = new DownloadItem(filePath, () => {
86+
updateProgress(callerWindow);
87+
}, () => {
88+
delete downloadQueue[filePath];
89+
updateProgress(callerWindow);
90+
});
91+
92+
updateProgress(callerWindow);
93+
94+
return filePath;
95+
} catch (error) {
96+
writeServerLog(`Error in get-download-path: ${error}`);
97+
}
98+
});
99+
100+
ipcMain.on('download-data-save-total', (event, filePath, total) => {
101+
const item = downloadQueue[filePath];
102+
if (item) {
103+
item.setTotal(total);
104+
}
105+
});
106+
107+
ipcMain.on('download-data-save-chunk', (event, filePath, chunk) => {
108+
const item = downloadQueue[filePath];
109+
if (item) {
110+
item.write(chunk);
111+
}
112+
});
113+
114+
ipcMain.on('download-data-save-end', (event, filePath, openFile=false) => {
115+
const item = downloadQueue[filePath];
116+
if (item) {
117+
item.remove();
118+
openFile && shell.openPath(filePath);
119+
}
120+
});
121+
}

runtime/src/js/pgadmin.js

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { spawn } from 'child_process';
1616
import { fileURLToPath } from 'url';
1717
import { setupMenu } from './menu.js';
1818
import contextMenu from 'electron-context-menu';
19-
import { CancelError, download } from 'electron-dl';
19+
import { setupDownloader } from './downloader.js';
2020

2121
const configStore = new Store({
2222
defaults: {
@@ -153,28 +153,6 @@ function reloadApp() {
153153
currWin.webContents.reload();
154154
}
155155

156-
async function desktopFileDownload(payload) {
157-
const currWin = BrowserWindow.getFocusedWindow();
158-
try {
159-
await download(currWin, payload.downloadUrl, {
160-
filename: payload.fileName,
161-
saveAs: payload.prompt_for_download_location,
162-
onProgress: (progress) => {
163-
currWin.webContents.send('download-progress', progress);
164-
},
165-
onCompleted: (item) => {
166-
currWin.webContents.send('download-complete', item);
167-
if (payload.automatically_open_downloaded_file)
168-
shell.openPath(item.path);
169-
},
170-
});
171-
} catch (error) {
172-
if (!(error instanceof CancelError)) {
173-
misc.writeServerLog(error);
174-
}
175-
}
176-
}
177-
178156
// This functions is used to start the pgAdmin4 server by spawning a
179157
// separate process.
180158
function startDesktopMode() {
@@ -192,8 +170,9 @@ function startDesktopMode() {
192170
process.env.PGADMIN_SERVER_MODE = 'OFF';
193171

194172
// Start Page URL
195-
startPageUrl = 'http://127.0.0.1:' + serverPort + '/?key=' + UUID;
196-
serverCheckUrl = 'http://127.0.0.1:' + serverPort + '/misc/ping?key=' + UUID;
173+
const baseUrl = `http://127.0.0.1:${serverPort}`;
174+
startPageUrl = `${baseUrl}/?key=${UUID}`;
175+
serverCheckUrl = `${baseUrl}/misc/ping?key=${UUID}`;
197176

198177
// Write Python Path, pgAdmin file path and command in log file.
199178
misc.writeServerLog('pgAdmin Runtime Environment');
@@ -356,6 +335,8 @@ function launchPgAdminWindow() {
356335
'reloadApp': reloadApp,
357336
});
358337

338+
setupDownloader();
339+
359340
pgAdminMainScreen.loadURL(startPageUrl);
360341

361342
const bounds = configStore.get('bounds');
@@ -429,7 +410,6 @@ ipcMain.on('log', (text) => ()=>{
429410
misc.writeServerLog(text);
430411
});
431412
ipcMain.on('reloadApp', reloadApp);
432-
ipcMain.on('onFileDownload', (_, payload) => desktopFileDownload(payload));
433413
ipcMain.handle('checkPortAvailable', async (_e, fixedPort)=>{
434414
try {
435415
await misc.getAvailablePort(fixedPort);

runtime/src/js/pgadmin_preload.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,9 @@ contextBridge.exposeInMainWorld('electronUI', {
2424
showSaveDialog: (options) => ipcRenderer.invoke('showSaveDialog', options),
2525
log: (text)=> ipcRenderer.send('log', text),
2626
reloadApp: ()=>{ipcRenderer.send('reloadApp');},
27-
onFileDownload: (payload) => ipcRenderer.send('onFileDownload', payload),
27+
// Download related functions
28+
getDownloadPath: (...args) => ipcRenderer.invoke('get-download-path', ...args),
29+
downloadDataSaveChunk: (...args) => ipcRenderer.send('download-data-save-chunk', ...args),
30+
downloadDataSaveTotal: (...args) => ipcRenderer.send('download-data-save-total', ...args),
31+
downloadDataSaveEnd: (...args) => ipcRenderer.send('download-data-save-end', ...args),
2832
});

runtime/src/js/progress.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/////////////////////////////////////////////////////////////
2+
//
3+
// pgAdmin 4 - PostgreSQL Tools
4+
//
5+
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
6+
// This software is released under the PostgreSQL Licence
7+
//
8+
//////////////////////////////////////////////////////////////
9+
10+
import { app } from 'electron';
11+
12+
export function setBadge(count) {
13+
const badgeCount = parseInt(count, 10);
14+
if (!isNaN(badgeCount)) {
15+
app.setBadgeCount(badgeCount);
16+
}
17+
}
18+
19+
// Function to clear badge
20+
export function clearBadge() {
21+
app.setBadgeCount(0);
22+
}
23+
24+
// Function to set progress bar
25+
export function setProgress(progress) {
26+
const progressValue = parseFloat(progress);
27+
if (this && !isNaN(progressValue) && progressValue >= 0 && progressValue <= 1) {
28+
this.setProgressBar(progressValue);
29+
} else if (this && progress === -1) {
30+
this.setProgressBar(-1);
31+
}
32+
}
33+
34+
// Function to clear progress
35+
export function clearProgress() {
36+
if (this) {
37+
this.setProgressBar(-1);
38+
}
39+
}

runtime/yarn.lock

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,7 @@ __metadata:
751751
languageName: node
752752
linkType: hard
753753

754-
"electron-store@npm:^10.0.0":
754+
"electron-store@npm:^10.0.1":
755755
version: 10.0.1
756756
resolution: "electron-store@npm:10.0.1"
757757
dependencies:
@@ -881,6 +881,19 @@ __metadata:
881881
languageName: node
882882
linkType: hard
883883

884+
"eslint-plugin-unused-imports@npm:^4.1.4":
885+
version: 4.1.4
886+
resolution: "eslint-plugin-unused-imports@npm:4.1.4"
887+
peerDependencies:
888+
"@typescript-eslint/eslint-plugin": ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
889+
eslint: ^9.0.0 || ^8.0.0
890+
peerDependenciesMeta:
891+
"@typescript-eslint/eslint-plugin":
892+
optional: true
893+
checksum: 1f4ce3e3972699345513840f3af1b783033dbc3a3e85b62ce12b3f6a89fd8c92afe46d0c00af40bacb14465445983ba0ccc326a6fd5132553061fb0e47bcba19
894+
languageName: node
895+
linkType: hard
896+
884897
"eslint-scope@npm:^8.3.0":
885898
version: 8.3.0
886899
resolution: "eslint-scope@npm:8.3.0"
@@ -1886,9 +1899,9 @@ __metadata:
18861899
axios: ^1.9.0
18871900
electron: 36.2.0
18881901
electron-context-menu: ^4.0.5
1889-
electron-dl: ^4.0.0
1890-
electron-store: ^10.0.0
1902+
electron-store: ^10.0.1
18911903
eslint: ^9.26.0
1904+
eslint-plugin-unused-imports: ^4.1.4
18921905
languageName: unknown
18931906
linkType: soft
18941907

web/pgadmin/dashboard/static/js/Dashboard.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import Replication from './Replication';
4040
import { getExpandCell } from '../../../static/js/components/PgReactTableStyled';
4141
import CodeMirror from '../../../static/js/components/ReactCodeMirror';
4242
import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded';
43-
import { downloadFile } from '../../../static/js/utils';
43+
import { downloadTextData } from '../../../static/js/download_utils';
4444
import RefreshButton from './components/RefreshButtons';
4545

4646
function parseData(data) {
@@ -451,7 +451,7 @@ function Dashboard({
451451
let fileName = 'data-' + new Date().getTime() + extension;
452452

453453
try {
454-
downloadFile(respData, fileName, `text/${type}`);
454+
downloadTextData(respData, fileName, `text/${type}`);
455455
} catch {
456456
setSsMsg(gettext('Failed to download the logs.'));
457457
}

web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import Uploader from './Uploader';
3232
import GridView from './GridView';
3333
import convert from 'convert-units';
3434
import PropTypes from 'prop-types';
35-
import { downloadBlob } from '../../../../../static/js/utils';
35+
import { downloadBlob } from '../../../../../static/js/download_utils';
3636
import ErrorBoundary from '../../../../../static/js/helpers/ErrorBoundary';
3737
import { MY_STORAGE } from './FileManagerConstants';
3838
import _ from 'lodash';

0 commit comments

Comments
 (0)