├── .babelrc ├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── pack.js ├── package-lock.json ├── package.json ├── resources ├── child.plist ├── dockface.png ├── icns │ ├── Icon1024.png │ ├── MyIcon.icns │ └── generate ├── parent.plist └── upstash.png ├── src ├── main │ ├── index.ts │ ├── menu.ts │ └── windowManager.ts └── renderer │ ├── images │ └── design.sketch │ ├── photon │ ├── css │ │ ├── photon.css │ │ └── photon.min.css │ └── fonts │ │ ├── photon-entypo.eot │ │ ├── photon-entypo.svg │ │ ├── photon-entypo.ttf │ │ └── photon-entypo.woff │ ├── redux │ ├── actions │ │ ├── connection.js │ │ ├── favorites.js │ │ ├── index.js │ │ ├── instances.js │ │ ├── patterns.js │ │ └── sizes.js │ ├── middlewares │ │ ├── createThunkReplyMiddleware.js │ │ └── index.js │ ├── persistEnhancer.js │ ├── reducers │ │ ├── activeInstanceKey.js │ │ ├── favorites.js │ │ ├── index.js │ │ ├── instances.js │ │ ├── patterns.js │ │ └── sizes.js │ └── store.js │ ├── storage │ ├── Favorites.js │ ├── Patterns.js │ ├── Sizes.js │ └── index.js │ ├── styles │ ├── global.scss │ ├── native.scss │ └── photon.scss │ ├── utils.ts │ ├── vendors │ └── jquery.terminal │ │ └── index.css │ └── windows │ ├── MainWindow │ ├── InstanceContent │ │ ├── ConnectionSelectorContainer │ │ │ ├── Config │ │ │ │ ├── index.jsx │ │ │ │ └── index.scss │ │ │ ├── Favorite.jsx │ │ │ └── index.jsx │ │ ├── DatabaseContainer │ │ │ ├── AddButton │ │ │ │ ├── index.jsx │ │ │ │ └── index.scss │ │ │ ├── Content │ │ │ │ ├── Config │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ ├── Footer.jsx │ │ │ │ ├── KeyContent │ │ │ │ │ ├── BaseContent │ │ │ │ │ │ ├── Editor │ │ │ │ │ │ │ ├── index.jsx │ │ │ │ │ │ │ └── index.scss │ │ │ │ │ │ ├── HashContent.jsx │ │ │ │ │ │ ├── ListContent.jsx │ │ │ │ │ │ ├── SetContent.jsx │ │ │ │ │ │ ├── SortHeaderCell.jsx │ │ │ │ │ │ ├── StringContent.jsx │ │ │ │ │ │ ├── ZSetContent.jsx │ │ │ │ │ │ ├── index.jsx │ │ │ │ │ │ └── index.scss │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ ├── TabBar │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ ├── Terminal │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ └── index.jsx │ │ │ ├── ContentEditable │ │ │ │ ├── index.jsx │ │ │ │ └── index.scss │ │ │ ├── KeyBrowser │ │ │ │ ├── Footer.jsx │ │ │ │ ├── KeyList │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ ├── PatternList │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.scss │ │ │ │ └── index.jsx │ │ │ ├── index.jsx │ │ │ └── index.scss │ │ ├── Modal │ │ │ ├── icon.png │ │ │ ├── index.jsx │ │ │ ├── index.scss │ │ │ └── warning.png │ │ └── index.jsx │ ├── InstanceTabs │ │ ├── Tab.tsx │ │ ├── Tabs.tsx │ │ ├── index.tsx │ │ └── main.scss │ ├── entry.jsx │ └── index.jsx │ └── PatternManagerWindow │ ├── app.scss │ ├── entry.jsx │ └── index.jsx ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "buffer" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-proposal-object-rest-spread", 7 | "@babel/plugin-syntax-dynamic-import", 8 | "@babel/plugin-proposal-class-properties" 9 | ], 10 | "presets": [ 11 | [ 12 | "@babel/preset-env", 13 | { 14 | "targets": { 15 | "chrome": "69" 16 | } 17 | } 18 | ], 19 | "@babel/preset-react" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [luin] 4 | custom: https://apps.apple.com/us/app/medis-2-gui-for-redis/id1579200037 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | npm-debug.log 5 | *.provisionprofile 6 | .awcache 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.6.1](https://github.com/luin/medis/compare/v0.5.0...v0.6.1) (2017-02-19) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * detect database number for Heroku Redis ([f2c6d7e](https://github.com/luin/medis/commit/f2c6d7e)), closes [#55](https://github.com/luin/medis/issues/55) [#52](https://github.com/luin/medis/issues/52) 8 | * UI for edit button ([3599392](https://github.com/luin/medis/commit/3599392)) 9 | * zset delete wrong element when sorting desc ([3d3f29a](https://github.com/luin/medis/commit/3d3f29a)), closes [#60](https://github.com/luin/medis/issues/60) 10 | 11 | ### Features 12 | 13 | * support search/find within a key ([9ecce73](https://github.com/luin/medis/commit/9ecce73)), closes [#61](https://github.com/luin/medis/issues/61) 14 | 15 | 16 | 17 | 18 | # [0.6.0](https://github.com/luin/medis/compare/v0.5.0...v0.6.0) (2017-02-19) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * detect database number for Heroku Redis ([f2c6d7e](https://github.com/luin/medis/commit/f2c6d7e)), closes [#55](https://github.com/luin/medis/issues/55) [#52](https://github.com/luin/medis/issues/52) 24 | * UI for edit button ([3599392](https://github.com/luin/medis/commit/3599392)) 25 | * zset delete wrong element when sorting desc ([3d3f29a](https://github.com/luin/medis/commit/3d3f29a)), closes [#60](https://github.com/luin/medis/issues/60) 26 | 27 | ### Features 28 | 29 | * support search/find within a key ([9ecce73](https://github.com/luin/medis/commit/9ecce73)), closes [#61](https://github.com/luin/medis/issues/61) 30 | 31 | 32 | 33 | 34 | # [0.5.0](https://github.com/luin/medis/compare/v0.3.0...v0.5.0) (2016-12-04) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * check err first before update the database status ([dd46cc3](https://github.com/luin/medis/commit/dd46cc3)) 40 | * clear the state before leaving the favorite page ([498a077](https://github.com/luin/medis/commit/498a077)) 41 | * don't show error multiple times when lost connection to SSH tunnel ([2b732bd](https://github.com/luin/medis/commit/2b732bd)) 42 | * fix psubscribe not working. Close #32 ([586a943](https://github.com/luin/medis/commit/586a943)), closes [#32](https://github.com/luin/medis/issues/32) 43 | * provide details error when connection is failed ([99d2757](https://github.com/luin/medis/commit/99d2757)) 44 | * tweak config panel style ([d92faf2](https://github.com/luin/medis/commit/d92faf2)) 45 | * ui issues when switching between tabs ([330f52f](https://github.com/luin/medis/commit/330f52f)), closes [#1](https://github.com/luin/medis/issues/1) 46 | 47 | ### Features 48 | 49 | * add support for SSL connection. ([ca29384](https://github.com/luin/medis/commit/ca29384)), closes [#41](https://github.com/luin/medis/issues/41) 50 | * allow quick connecting by double clicking ([53a284e](https://github.com/luin/medis/commit/53a284e)) 51 | * support Elastic Cache Redis & RedisLabs for selecting database ([18e5629](https://github.com/luin/medis/commit/18e5629)) 52 | * support to duplicate favorites ([c2bc438](https://github.com/luin/medis/commit/c2bc438)), closes [#30](https://github.com/luin/medis/issues/30) 53 | * use Consolas font instead ([bd9d1c9](https://github.com/luin/medis/commit/bd9d1c9)), closes [#2](https://github.com/luin/medis/issues/2) [#39](https://github.com/luin/medis/issues/39) 54 | 55 | 56 | 57 | 58 | # [0.5.0](https://github.com/luin/medis/compare/v0.3.0...v0.5.0) (2016-12-04) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * check err first before update the database status ([dd46cc3](https://github.com/luin/medis/commit/dd46cc3)) 64 | * clear the state before leaving the favorite page ([498a077](https://github.com/luin/medis/commit/498a077)) 65 | * don't show error multiple times when lost connection to SSH tunnel ([2b732bd](https://github.com/luin/medis/commit/2b732bd)) 66 | * fix psubscribe not working. Close #32 ([586a943](https://github.com/luin/medis/commit/586a943)), closes [#32](https://github.com/luin/medis/issues/32) 67 | * provide details error when connection is failed ([99d2757](https://github.com/luin/medis/commit/99d2757)) 68 | * tweak config panel style ([d92faf2](https://github.com/luin/medis/commit/d92faf2)) 69 | * ui issues when switching between tabs ([330f52f](https://github.com/luin/medis/commit/330f52f)), closes [#1](https://github.com/luin/medis/issues/1) 70 | 71 | ### Features 72 | 73 | * add support for SSL connection. ([ca29384](https://github.com/luin/medis/commit/ca29384)), closes [#41](https://github.com/luin/medis/issues/41) 74 | * allow quick connecting by double clicking ([53a284e](https://github.com/luin/medis/commit/53a284e)) 75 | * support Elastic Cache Redis & RedisLabs for selecting database ([18e5629](https://github.com/luin/medis/commit/18e5629)) 76 | * support to duplicate favorites ([c2bc438](https://github.com/luin/medis/commit/c2bc438)), closes [#30](https://github.com/luin/medis/issues/30) 77 | * use Consolas font instead ([bd9d1c9](https://github.com/luin/medis/commit/bd9d1c9)), closes [#2](https://github.com/luin/medis/issues/2) [#39](https://github.com/luin/medis/issues/39) 78 | 79 | 80 | 81 | 82 | # [0.3.0](https://github.com/luin/medis/compare/v0.2.1...v0.3.0) (2016-03-25) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * **windows:** hide app menu in Windows version ([d31bd6c](https://github.com/luin/medis/commit/d31bd6c)) 88 | 89 | ### Features 90 | 91 | * support inputing spaces in terminal ([04e7bcf](https://github.com/luin/medis/commit/04e7bcf)), closes [#24](https://github.com/luin/medis/issues/24) 92 | 93 | 94 | 95 | 96 | ## [0.2.1](https://github.com/luin/medis/compare/v0.2.0...v0.2.1) (2016-02-01) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * **ssh:** fix ssh password being ignored ([4dfdbcd](https://github.com/luin/medis/commit/4dfdbcd)), closes [#13](https://github.com/luin/medis/issues/13) 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2022 Zihua Li 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Medis 2 | 3 | ### Notice: We just released Medis 2! 🚀🚀🚀 4 | 5 | Compared to Medis (this repo), Medis 2 provides more delightful features, such as **tree view** (yes, finally!), streams, alert mode, **dark mode**, and more. Besides that, Medis 2 is rewritten from the beginning with native technology, making it more morden, beautiful, and fast! 6 | 7 | What's more, **Medis 2 is free 💰 to download**! Don't hesitate, download it from the App Store now and try it out! 8 | 9 | [![Download on the App Store](http://getmedis.com/download.svg)](https://apps.apple.com/us/app/medis-2-gui-for-redis/id1579200037?mt=12) 10 | 11 | _(or searching "Medis 2" on macOS App Store if the above link doesn't work for you. Also, you can download the app directly from the [official website](https://getmedis.com/))_ 12 | 13 | ![Medis](http://getmedis.com/screen.png) 14 | 15 | --- 16 | 17 | Medis is a beautiful, easy-to-use Redis management application built on the modern web with [Electron](https://github.com/atom/electron), [React](https://facebook.github.io/react/), and [Redux](https://github.com/rackt/redux). It's powered by many awesome Node.js modules, especially [ioredis](https://github.com/luin/ioredis) and [ssh2](https://github.com/mscdex/ssh2). 18 | 19 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 20 | 21 | Medis starts with all the basic features you need: 22 | 23 | - Keys viewing/editing 24 | - SSH Tunnel for connecting with remote servers 25 | - Terminal for executing custom commands 26 | - Config viewing/editing 27 | 28 | It also supports many advanced features: 29 | 30 | - JSON/MessagePack format viewing/editing and built-in highlighting/validator 31 | - Working with millions keys and key members without blocking the redis server 32 | - Pattern manager for easy selecting a sub group of keys. 33 | 34 | **Note**: Medis only supports Redis >= 2.8 version because `SCAN` command was introduced since 2.8. `SCAN` is very useful to get key list without blocking the server, which is crucial to the production environment. Because the latest stable is 5.0 and 2.6 is a very old version, Medis doesn't support it. 35 | 36 |
37 | 38 | ## Download Medis on Windows 39 | 40 | You can download compiled installer of Medis for Windows from the below page 41 | [download page](https://github.com/classfellow/medis/releases/tag/win) 42 | 43 | ## Download Medis on Mac 44 | 45 | You can download compiled versions of Medis for Mac OS X from [the release page](https://github.com/luin/medis/releases). 46 | 47 | ## Running Locally 48 | 49 | 1. Install dependencies 50 | 51 | ``` 52 | $ npm install 53 | ``` 54 | 55 | 2. Compile assets: 56 | 57 | ``` 58 | $ npm run pack 59 | ``` 60 | 61 | 3. Run with Electron: 62 | 63 | ``` 64 | $ npm start 65 | ``` 66 | 67 | ## Connect to Heroku 68 | 69 | Medis can connect to Heroku Redis addon to manage your data. You just need to call `heroku redis:credentials --app APP` to get your redis credential: 70 | 71 | ```shell 72 | $ heroku redis:credentials --app YOUR_APP 73 | redis://x:PASSWORD@HOST:PORT 74 | ``` 75 | 76 | And then input `HOST`, `PORT` and `PASSWORD` to the connection tab. 77 | 78 | ## I Love This. How do I Help? 79 | 80 | - Simply star this repository :-) 81 | - Help us spread the world on Facebook and Twitter 82 | - Contribute Code! We're developers! (See Roadmap below) 83 | - Medis is available on the Mac App Store as a paid software. I'll be very grateful if you'd like to buy it to encourage me to continue maintaining Medis. There are no additional features comparing with the open-sourced version, except the fact that you can enjoy auto updating that brought by the Mac App Store.
[![Download on the App Store](http://getmedis.com/download.svg)](https://apps.apple.com/us/app/medis-2-gui-for-redis/id1579200037?mt=12) 84 | 85 | ## Roadmap 86 | 87 | - Windows and Linux version (with electron-packager) 88 | - Support for SaaS Redis services 89 | - Lua script editor 90 | - Cluster management 91 | - GEO keys supporting 92 | 93 | ## Contributors 94 | 95 |

luin

kvnsmth

dpde

ogasawaraShinnosuke

naholyr

hlobil

Janpot

96 | 97 | ## License 98 | 99 | MIT 100 | -------------------------------------------------------------------------------- /bin/pack.js: -------------------------------------------------------------------------------- 1 | const packager = require('electron-packager') 2 | const path = require('path') 3 | const pkg = require('../package') 4 | const flat = require('electron-osx-sign').flat 5 | 6 | const resourcesPath = path.join(__dirname, '..', 'resources') 7 | 8 | packager({ 9 | dir: path.join(__dirname, '..'), 10 | appCopyright: '© 2019, Zihua Li', 11 | asar: true, 12 | overwrite: true, 13 | electronVersion: pkg.electronVersion, 14 | icon: path.join(resourcesPath, 'icns', 'MyIcon'), 15 | out: path.join(__dirname, '..', 'dist', 'out'), 16 | platform: 'mas', 17 | appBundleId: `li.zihua.${pkg.name}`, 18 | appCategoryType: 'public.app-category.developer-tools', 19 | osxSign: { 20 | type: process.env.NODE_ENV === 'production' ? 'distribution' : 'development', 21 | entitlements: path.join(resourcesPath, 'parent.plist'), 22 | 'entitlements-inherit': path.join(resourcesPath, 'child.plist') 23 | } 24 | }).then((res) => { 25 | const app = path.join(res[0], `${pkg.productName}.app`) 26 | console.log('flating...', app) 27 | flat({ app }, function done (err) { 28 | if (err) { 29 | throw err 30 | } 31 | process.exit(0); 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medis", 3 | "description": "GUI for Redis", 4 | "productName": "Medis", 5 | "version": "1.0.3", 6 | "electronVersion": "4.0.0", 7 | "license": "MIT", 8 | "author": "luin (http://zihua.li)", 9 | "main": "dist/main", 10 | "scripts": { 11 | "build": "rm -rf dist && webpack", 12 | "watch": "WEBPACK_WATCH=true npm run build", 13 | "start": "electron .", 14 | "pack": "NODE_ENV=production npm run build && node bin/pack.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/luin/medis.git" 19 | }, 20 | "dependencies": { 21 | "electron": "^4.2.2", 22 | "electron-context-menu": "^0.12.1", 23 | "fixed-data-table-contextmenu": "^1.7.2", 24 | "ioredis": "^4.9.3", 25 | "jquery": "^3.4.1", 26 | "jquery.terminal": "^2.5.1", 27 | "lodash.escape": "^4.0.1", 28 | "lodash.sortedindexby": "^4.6.0", 29 | "medis-react-codemirror": "^1.1.1", 30 | "redis-commands": "^1.5.0", 31 | "ssh2": "^0.8.9", 32 | "xterm": "^3.13.1" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.4.4", 36 | "@babel/plugin-proposal-class-properties": "^7.4.4", 37 | "@babel/plugin-proposal-object-rest-spread": "^7.4.4", 38 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 39 | "@babel/preset-env": "^7.4.4", 40 | "@babel/preset-react": "^7.0.0", 41 | "@types/jquery": "^3.3.29", 42 | "@types/react": "^16.8.17", 43 | "@types/react-dom": "^16.8.4", 44 | "@types/redux-actions": "^2.6.1", 45 | "awesome-typescript-loader": "^5.2.1", 46 | "babel-loader": "^8.0.6", 47 | "codemirror": "^5.46.0", 48 | "css-loader": "^2.1.1", 49 | "electron-osx-sign": "^0.4.4", 50 | "electron-packager": "^13.1.1", 51 | "file-loader": "^3.0.1", 52 | "html-webpack-plugin": "^3.2.0", 53 | "human-format": "^0.10.1", 54 | "immutable": "^3.8.1", 55 | "json-editor": "^0.7.23", 56 | "jsonlint": "^1.6.2", 57 | "jsx-loader": "^0.13.2", 58 | "lint": "^1.1.2", 59 | "lodash.clone": "^4.5.0", 60 | "lodash.zip": "^4.2.0", 61 | "mini-css-extract-plugin": "^0.6.0", 62 | "minimatch": "^3.0.4", 63 | "msgpack5": "^4.2.1", 64 | "node-sass": "^4.12.0", 65 | "prop-types": "^15.7.2", 66 | "react": "^16.8.6", 67 | "react-addons-css-transition-group": "^15.5.2", 68 | "react-document-title": "^2.0.1", 69 | "react-dom": "^16.8.6", 70 | "react-redux": "^7.0.3", 71 | "react-sortable-hoc": "^1.9.1", 72 | "react-split-pane": "^0.1.87", 73 | "redis-splitargs": "^1.0.1", 74 | "redux": "^4.0.1", 75 | "redux-actions": "^2.6.5", 76 | "reselect": "^4.0.0", 77 | "sass-loader": "^7.1.0", 78 | "sortablejs": "^1.9.0", 79 | "typescript": "^3.4.5", 80 | "url-loader": "^1.1.2", 81 | "webpack": "^4.31.0", 82 | "webpack-bundle-analyzer": "^3.3.2", 83 | "webpack-cli": "^3.3.2" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /resources/child.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/dockface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/resources/dockface.png -------------------------------------------------------------------------------- /resources/icns/Icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/resources/icns/Icon1024.png -------------------------------------------------------------------------------- /resources/icns/MyIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/resources/icns/MyIcon.icns -------------------------------------------------------------------------------- /resources/icns/generate: -------------------------------------------------------------------------------- 1 | mkdir MyIcon.iconset 2 | sips -z 16 16 Icon1024.png --out MyIcon.iconset/icon_16x16.png 3 | sips -z 32 32 Icon1024.png --out MyIcon.iconset/icon_16x16@2x.png 4 | sips -z 32 32 Icon1024.png --out MyIcon.iconset/icon_32x32.png 5 | sips -z 64 64 Icon1024.png --out MyIcon.iconset/icon_32x32@2x.png 6 | sips -z 128 128 Icon1024.png --out MyIcon.iconset/icon_128x128.png 7 | sips -z 256 256 Icon1024.png --out MyIcon.iconset/icon_128x128@2x.png 8 | sips -z 256 256 Icon1024.png --out MyIcon.iconset/icon_256x256.png 9 | sips -z 512 512 Icon1024.png --out MyIcon.iconset/icon_256x256@2x.png 10 | sips -z 512 512 Icon1024.png --out MyIcon.iconset/icon_512x512.png 11 | cp Icon1024.png MyIcon.iconset/icon_512x512@2x.png 12 | iconutil -c icns MyIcon.iconset 13 | rm -R MyIcon.iconset 14 | -------------------------------------------------------------------------------- /resources/parent.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resources/upstash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/resources/upstash.png -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import {app, Menu, ipcMain} from 'electron' 2 | import windowManager from './windowManager' 3 | import menu from './menu' 4 | const contextMenu = require('electron-context-menu'); 5 | 6 | contextMenu({ 7 | // showInspectElement: true, 8 | }) 9 | 10 | ipcMain.on('create patternManager', function (event, arg) { 11 | windowManager.create('patternManager', arg) 12 | }) 13 | 14 | ipcMain.on('dispatch', function (event, action, arg) { 15 | windowManager.dispatch(action, arg) 16 | }) 17 | 18 | // Quit when all windows are closed. 19 | app.on('window-all-closed', function () { 20 | if (process.platform !== 'darwin') { 21 | app.quit() 22 | } 23 | }) 24 | 25 | // This method will be called when Electron has finished 26 | // initialization and is ready to create browser windows. 27 | app.on('ready', function () { 28 | Menu.setApplicationMenu(menu) 29 | windowManager.create() 30 | 31 | app.on('activate', function (_, hasVisibleWindows) { 32 | if (!hasVisibleWindows) { 33 | windowManager.create() 34 | } 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import {app, Menu, MenuItemConstructorOptions} from 'electron' 2 | import windowManager from './windowManager' 3 | 4 | const menuTemplates: MenuItemConstructorOptions[] = [{ 5 | label: 'File', 6 | submenu: [{ 7 | label: 'New Connection Window', 8 | accelerator: 'CmdOrCtrl+N', 9 | click() { 10 | windowManager.create() 11 | } 12 | }, { 13 | label: 'New Connection Tab', 14 | accelerator: 'CmdOrCtrl+T', 15 | click() { 16 | windowManager.current.webContents.send('action', 'createInstance') 17 | } 18 | }, { 19 | type: 'separator' 20 | }, { 21 | label: 'Close Window', 22 | accelerator: 'Shift+CmdOrCtrl+W', 23 | click() { 24 | windowManager.current.close() 25 | } 26 | }, { 27 | label: 'Close Tab', 28 | accelerator: 'CmdOrCtrl+W', 29 | click() { 30 | windowManager.current.webContents.send('action', 'delInstance') 31 | } 32 | }] 33 | }, { 34 | label: 'Edit', 35 | submenu: [{ 36 | label: 'Undo', 37 | accelerator: 'CmdOrCtrl+Z', 38 | role: 'undo' 39 | }, { 40 | label: 'Redo', 41 | accelerator: 'Shift+CmdOrCtrl+Z', 42 | role: 'redo' 43 | }, { 44 | type: 'separator' 45 | }, { 46 | label: 'Cut', 47 | accelerator: 'CmdOrCtrl+X', 48 | role: 'cut' 49 | }, { 50 | label: 'Copy', 51 | accelerator: 'CmdOrCtrl+C', 52 | role: 'copy' 53 | }, { 54 | label: 'Paste', 55 | accelerator: 'CmdOrCtrl+V', 56 | role: 'paste' 57 | }, { 58 | label: 'Select All', 59 | accelerator: 'CmdOrCtrl+A', 60 | role: 'selectall' 61 | }] 62 | }, { 63 | label: 'View', 64 | submenu: [{ 65 | label: 'Reload', 66 | accelerator: 'CmdOrCtrl+R', 67 | click(item, focusedWindow) { 68 | if (focusedWindow) { 69 | focusedWindow.reload() 70 | } 71 | } 72 | }, { 73 | label: 'Toggle Full Screen', 74 | accelerator: (function () { 75 | if (process.platform === 'darwin') { 76 | return 'Ctrl+Command+F' 77 | } 78 | return 'F11' 79 | })(), 80 | click(item, focusedWindow) { 81 | if (focusedWindow) { 82 | focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) 83 | } 84 | } 85 | }, { 86 | label: 'Toggle Developer Tools', 87 | accelerator: (function () { 88 | if (process.platform === 'darwin') { 89 | return 'Alt+Command+I' 90 | } 91 | return 'Ctrl+Shift+I' 92 | })(), 93 | click(item, focusedWindow) { 94 | if (focusedWindow) { 95 | focusedWindow.webContents.toggleDevTools() 96 | } 97 | } 98 | }] 99 | }, { 100 | label: 'Window', 101 | role: 'window', 102 | submenu: [{ 103 | label: 'Minimize', 104 | accelerator: 'CmdOrCtrl+M', 105 | role: 'minimize' 106 | }, { 107 | label: 'Close', 108 | accelerator: 'CmdOrCtrl+W', 109 | role: 'close' 110 | }] 111 | }, { 112 | label: 'Help', 113 | role: 'help', 114 | submenu: [{ 115 | label: 'Report an Issue...', 116 | click() { 117 | require('shell').openExternal('mailto:medis@zihua.li') 118 | } 119 | }, { 120 | label: 'Learn More', 121 | click() { 122 | require('shell').openExternal('http://getmedis.com') 123 | } 124 | }] 125 | }] 126 | 127 | let baseIndex = 0 128 | if (process.platform == 'darwin') { 129 | baseIndex = 1 130 | menuTemplates.unshift({ 131 | label: app.getName(), 132 | submenu: [{ 133 | label: 'About ' + app.getName(), 134 | role: 'about' 135 | }, { 136 | type: 'separator' 137 | }, { 138 | label: 'Services', 139 | role: 'services', 140 | submenu: [] 141 | }, { 142 | type: 'separator' 143 | }, { 144 | label: 'Hide ' + app.getName(), 145 | accelerator: 'Command+H', 146 | role: 'hide' 147 | }, { 148 | label: 'Hide Others', 149 | accelerator: 'Command+Shift+H', 150 | role: 'hideothers' 151 | }, { 152 | label: 'Show All', 153 | role: 'unhide' 154 | }, { 155 | type: 'separator' 156 | }, { 157 | label: 'Quit', 158 | accelerator: 'Command+Q', 159 | click() { 160 | app.quit() 161 | } 162 | }] 163 | }) 164 | } 165 | 166 | const menu = Menu.buildFromTemplate(menuTemplates) 167 | 168 | if (process.env.NODE_ENV === 'production') { 169 | const {submenu} = (menu.items[baseIndex + 2] as any) 170 | submenu.items[0].visible = false 171 | submenu.items[2].visible = false 172 | } 173 | 174 | const {submenu} = (menu.items[baseIndex + 0] as any) 175 | windowManager.on('blur', function () { 176 | submenu.items[3].enabled = false 177 | submenu.items[4].enabled = false 178 | }) 179 | 180 | windowManager.on('focus', function () { 181 | const {submenu} = (menu.items[baseIndex + 0] as any) 182 | submenu.items[3].enabled = true 183 | submenu.items[4].enabled = true 184 | }) 185 | 186 | export default menu 187 | -------------------------------------------------------------------------------- /src/main/windowManager.ts: -------------------------------------------------------------------------------- 1 | import {app, BrowserWindow, BrowserWindowConstructorOptions} from 'electron' 2 | import path from 'path' 3 | import EventEmitter from 'events' 4 | 5 | class WindowManager extends EventEmitter { 6 | windows = new Set() 7 | 8 | constructor() { 9 | super() 10 | app.on('browser-window-blur', this.emit.bind(this, 'blur')) 11 | app.on('browser-window-focus', this.emit.bind(this, 'focus')) 12 | } 13 | 14 | get current() { 15 | return BrowserWindow.getFocusedWindow() || this.create() 16 | } 17 | 18 | create(type = 'main', arg?: any): BrowserWindow { 19 | const option: BrowserWindowConstructorOptions = { 20 | backgroundColor: '#ececec', 21 | webPreferences: { 22 | nodeIntegration: true 23 | } 24 | } 25 | if (type === 'main') { 26 | option.width = 960 27 | option.height = 600 28 | option.show = false 29 | option.minWidth = 840 30 | option.minHeight = 400 31 | } else if (type === 'patternManager') { 32 | option.width = 600 33 | option.height = 300 34 | option.title = 'Manage Patterns' 35 | option.resizable = true 36 | option.fullscreen = false 37 | } 38 | 39 | let start: number 40 | const newWindow = new BrowserWindow(option) 41 | if (!option.show) { 42 | newWindow.once('ready-to-show', () => { 43 | console.log('start time: ', Date.now() - start) 44 | newWindow.show() 45 | }) 46 | } 47 | 48 | start = Date.now() 49 | newWindow.loadFile(path.resolve(__dirname, `../renderer/${type}.html`), {query: {arg}}) 50 | 51 | this._register(newWindow) 52 | 53 | return newWindow 54 | } 55 | 56 | _register(win: BrowserWindow): void { 57 | this.windows.add(win) 58 | win.on('closed', () => { 59 | this.windows.delete(win) 60 | if (!BrowserWindow.getFocusedWindow()) { 61 | this.emit('blur') 62 | } 63 | }) 64 | this.emit('focus') 65 | } 66 | 67 | dispatch(action: string, args: any) { 68 | this.windows.forEach(win => { 69 | if (win && win.webContents) { 70 | win.webContents.send('action', action, args) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | export default new WindowManager() 77 | -------------------------------------------------------------------------------- /src/renderer/images/design.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/images/design.sketch -------------------------------------------------------------------------------- /src/renderer/photon/fonts/photon-entypo.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/photon/fonts/photon-entypo.eot -------------------------------------------------------------------------------- /src/renderer/photon/fonts/photon-entypo.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/photon/fonts/photon-entypo.ttf -------------------------------------------------------------------------------- /src/renderer/photon/fonts/photon-entypo.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/photon/fonts/photon-entypo.woff -------------------------------------------------------------------------------- /src/renderer/redux/actions/connection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {createAction} from 'Utils'; 4 | import {Client} from 'ssh2'; 5 | import net from 'net'; 6 | import Redis from 'ioredis'; 7 | 8 | function getIndex(getState) { 9 | const {activeInstanceKey, instances} = getState() 10 | return instances.findIndex(instance => instance.get('key') === activeInstanceKey) 11 | } 12 | 13 | export const updateConnectStatus = createAction('UPDATE_CONNECT_STATUS', status => ({getState, next}) => { 14 | next({status, index: getIndex(getState)}) 15 | }) 16 | 17 | export const disconnect = createAction('DISCONNECT', () => ({getState, next}) => { 18 | next({index: getIndex(getState)}) 19 | }) 20 | 21 | export const connectToRedis = createAction('CONNECT', config => ({getState, dispatch, next}) => { 22 | let sshErrorThrown = false 23 | let redisErrorMessage 24 | 25 | if (config.ssh) { 26 | dispatch(updateConnectStatus('SSH connecting...')) 27 | 28 | const conn = new Client(); 29 | conn.on('ready', () => { 30 | const server = net.createServer(function (sock) { 31 | conn.forwardOut(sock.remoteAddress, sock.remotePort, config.host, config.port, (err, stream) => { 32 | if (err) { 33 | sock.end() 34 | } else { 35 | sock.pipe(stream).pipe(sock) 36 | } 37 | }) 38 | }).listen(0, function () { 39 | handleRedis(config, { host: '127.0.0.1', port: server.address().port }) 40 | }) 41 | }).on('error', err => { 42 | sshErrorThrown = true; 43 | dispatch(disconnect()); 44 | alert(`SSH Error: ${err.message}`); 45 | }) 46 | 47 | try { 48 | const connectionConfig = { 49 | host: config.sshHost, 50 | port: config.sshPort || 22, 51 | username: config.sshUser 52 | } 53 | if (config.sshKey) { 54 | conn.connect(Object.assign(connectionConfig, { 55 | privateKey: config.sshKey, 56 | passphrase: config.sshKeyPassphrase 57 | })) 58 | } else { 59 | conn.connect(Object.assign(connectionConfig, { 60 | password: config.sshPassword 61 | })) 62 | } 63 | } catch (err) { 64 | dispatch(disconnect()); 65 | alert(`SSH Error: ${err.message}`); 66 | } 67 | } else { 68 | handleRedis(config); 69 | } 70 | 71 | function handleRedis(config, override) { 72 | dispatch(updateConnectStatus('Redis connecting...')) 73 | if (config.ssl) { 74 | config.tls = { 75 | rejectUnauthorized: false 76 | }; 77 | if (config.tlsca) config.tls.ca = config.tlsca; 78 | if (config.tlskey) config.tls.key = config.tlskey; 79 | if (config.tlscert) config.tls.cert = config.tlscert; 80 | } 81 | const redis = new Redis(Object.assign({}, config, override, { 82 | retryStrategy() { 83 | return false; 84 | } 85 | })); 86 | redis.defineCommand('setKeepTTL', { 87 | numberOfKeys: 1, 88 | lua: 'local ttl = redis.call("pttl", KEYS[1]) if ttl > 0 then return redis.call("SET", KEYS[1], ARGV[1], "PX", ttl) else return redis.call("SET", KEYS[1], ARGV[1]) end' 89 | }); 90 | redis.defineCommand('lremindex', { 91 | numberOfKeys: 1, 92 | lua: 'local FLAG = "$$#__@DELETE@_REDIS_@PRO@__#$$" redis.call("lset", KEYS[1], ARGV[1], FLAG) redis.call("lrem", KEYS[1], 1, FLAG)' 93 | }); 94 | redis.defineCommand('duplicateKey', { 95 | numberOfKeys: 2, 96 | lua: 'local dump = redis.call("dump", KEYS[1]) local pttl = 0 if ARGV[1] == "TTL" then pttl = redis.call("pttl", KEYS[1]) end return redis.call("restore", KEYS[2], pttl, dump)' 97 | }); 98 | redis.once('connect', function () { 99 | redis.ping((err, res) => { 100 | if (err) { 101 | if (err.message === 'Ready check failed: NOAUTH Authentication required.') { 102 | err.message = 'Redis Error: Access denied. Please double-check your password.'; 103 | } 104 | if (err.message !== 'Connection is closed.') { 105 | alert(err.message); 106 | redis.disconnect(); 107 | } 108 | return; 109 | } 110 | const version = redis.serverInfo.redis_version; 111 | if (version && version.length >= 5) { 112 | const versionNumber = Number(version[0] + version[2]); 113 | if (versionNumber < 28) { 114 | alert('Medis only supports Redis >= 2.8 because servers older than 2.8 don\'t support SCAN command, which means it not possible to access keys without blocking Redis.'); 115 | dispatch(disconnect()); 116 | return; 117 | } 118 | } 119 | next({redis, config, index: getIndex(getState)}); 120 | }) 121 | }); 122 | redis.once('error', function (error) { 123 | redisErrorMessage = error; 124 | }); 125 | redis.once('end', function () { 126 | dispatch(disconnect()); 127 | if (!sshErrorThrown) { 128 | let msg = 'Redis Error: Connection failed. '; 129 | if (redisErrorMessage) { 130 | msg += `(${redisErrorMessage})`; 131 | } 132 | alert(msg); 133 | } 134 | }); 135 | } 136 | }) 137 | -------------------------------------------------------------------------------- /src/renderer/redux/actions/favorites.js: -------------------------------------------------------------------------------- 1 | import {createAction} from 'Utils'; 2 | import {fromJS} from 'immutable' 3 | import {Favorites} from '../../storage' 4 | 5 | 6 | export const createFavorite = createAction('CREATE_FAVORITE', (data) => { 7 | const key = `favorite-${Math.round(Math.random() * 100000)}` 8 | return Object.assign({key}, data) 9 | }) 10 | 11 | export const reloadFavorites = createAction('RELOAD_FAVORITES', Favorites.get) 12 | export const removeFavorite = createAction('REMOVE_FAVORITE') 13 | export const reorderFavorites = createAction('REORDER_FAVORITES') 14 | export const updateFavorite = createAction('UPDATE_FAVORITE', (key, data) => ({key, data})) 15 | -------------------------------------------------------------------------------- /src/renderer/redux/actions/index.js: -------------------------------------------------------------------------------- 1 | export * from './instances' 2 | export * from './favorites' 3 | export * from './patterns' 4 | export * from './connection' 5 | export * from './sizes' 6 | -------------------------------------------------------------------------------- /src/renderer/redux/actions/instances.js: -------------------------------------------------------------------------------- 1 | import {createAction} from 'Utils'; 2 | import {remote} from 'electron' 3 | import {getId} from 'Utils' 4 | 5 | export const createInstance = createAction('CREATE_INSTANCE', data => ( 6 | Object.assign({}, data, {key: getId('instance')}) 7 | )) 8 | 9 | export const selectInstance = createAction('SELECT_INSTANCE') 10 | 11 | export const moveInstance = createAction('MOVE_INSTANCE', (from, to) => ({getState, next}) => { 12 | const {instances} = getState() 13 | 14 | const [fromIndex, instance] = instances.findEntry(v => v.get('key') === from); 15 | const toIndex = instances.findIndex(v => v.get('key') === to); 16 | 17 | next({fromIndex, toIndex, activeInstanceKey: instance.get('key')}) 18 | }) 19 | 20 | export const delInstance = createAction('DEL_INSTANCE', key => ({getState, next}) => { 21 | const {activeInstanceKey, instances} = getState() 22 | if (!key) { 23 | key = activeInstanceKey 24 | } 25 | 26 | const targetIndex = instances.findIndex(instance => instance.get('key') === key); 27 | 28 | const ret = {activeInstanceKey, targetIndex} 29 | 30 | if (key === activeInstanceKey) { 31 | const item = instances.get(targetIndex + 1) || (targetIndex > 0 && instances.get(targetIndex - 1)) 32 | 33 | console.log('still', item, targetIndex, instances.size) 34 | if (item) { 35 | ret.activeInstanceKey = item.get('key') 36 | } else { 37 | remote.getCurrentWindow().close(); 38 | return; 39 | } 40 | } 41 | 42 | next(ret) 43 | }) 44 | -------------------------------------------------------------------------------- /src/renderer/redux/actions/patterns.js: -------------------------------------------------------------------------------- 1 | import {createAction} from 'Utils' 2 | import {Patterns} from '../../storage' 3 | 4 | 5 | export const createPattern = createAction('CREATE_PATTERN', (conn) => { 6 | const key = `pattern-${Math.round(Math.random() * 100000)}` 7 | return Object.assign({key, conn}) 8 | }) 9 | 10 | export const reloadPatterns = createAction('RELOAD_PATTERNS', Patterns.get) 11 | export const removePattern = createAction('REMOVE_PATTERN', (conn, index) => ({conn, index})) 12 | export const updatePattern = createAction('UPDATE_PATTERN', (conn, index, data) => ({conn, index, data})) 13 | -------------------------------------------------------------------------------- /src/renderer/redux/actions/sizes.js: -------------------------------------------------------------------------------- 1 | import {createAction} from 'Utils'; 2 | 3 | export const setSize = createAction('SET_SIZE', (type, value) => ({type, value: Number(value)})) 4 | -------------------------------------------------------------------------------- /src/renderer/redux/middlewares/createThunkReplyMiddleware.js: -------------------------------------------------------------------------------- 1 | function isThunkReply(action) { 2 | return typeof action.payload === 'function' && action.args 3 | } 4 | 5 | export default function createThunkReplyMiddleware(extraArgument) { 6 | return function ({dispatch, getState}) { 7 | return _next => action => { 8 | if (!isThunkReply(action)) { 9 | return _next(action) 10 | } 11 | 12 | function next(payload) { 13 | dispatch({payload, type: action.type}) 14 | } 15 | 16 | return action.payload(Object.assign({dispatch, getState, next}, extraArgument)) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/redux/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import createThunkReplyMiddleware from './createThunkReplyMiddleware' 2 | 3 | export {createThunkReplyMiddleware} 4 | -------------------------------------------------------------------------------- /src/renderer/redux/persistEnhancer.js: -------------------------------------------------------------------------------- 1 | import * as Storage from '../storage' 2 | 3 | const whiteList = [ 4 | {key: 'patterns', storage: 'Patterns'}, 5 | {key: 'favorites', storage: 'Favorites'}, 6 | {key: 'sizes', storage: 'Sizes'} 7 | ] 8 | 9 | export default function (store) { 10 | let lastState 11 | store.subscribe(() => { 12 | if (store.skipPersist) { 13 | return 14 | } 15 | const state = store.getState() 16 | whiteList.forEach(({key, storage}) => { 17 | const value = state[key] 18 | if (!lastState || value !== lastState[key]) { 19 | Storage[storage].set(value) 20 | } 21 | }) 22 | 23 | lastState = state 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/activeInstanceKey.js: -------------------------------------------------------------------------------- 1 | import {handleActions} from 'Utils' 2 | import { 3 | createInstance, 4 | selectInstance, 5 | moveInstance, 6 | delInstance 7 | } from 'Redux/actions' 8 | 9 | export const defaultInstanceKey = 'FIRST_INSTANCE' 10 | 11 | export const activeInstanceKey = handleActions(defaultInstanceKey, { 12 | [createInstance](state, data) { 13 | return data.key 14 | }, 15 | [selectInstance](state, data) { 16 | return data 17 | }, 18 | [moveInstance](state, {activeInstanceKey}) { 19 | return activeInstanceKey 20 | }, 21 | [delInstance](state, {activeInstanceKey}) { 22 | console.log('==delInstance', activeInstanceKey) 23 | return activeInstanceKey 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/favorites.js: -------------------------------------------------------------------------------- 1 | import {handleActions} from 'Utils' 2 | import { 3 | createFavorite, 4 | removeFavorite, 5 | updateFavorite, 6 | reorderFavorites, 7 | reloadFavorites 8 | } from 'Redux/actions' 9 | import {Favorites} from '../../storage' 10 | import {Map, fromJS} from 'immutable' 11 | 12 | function FavoriteFactory(data) { 13 | return Map(Object.assign({name: 'New Favorite'}, data)) 14 | } 15 | 16 | export const favorites = handleActions(fromJS(Favorites.get()), { 17 | [createFavorite](state, data) { 18 | return state.push(FavoriteFactory(data)) 19 | }, 20 | [removeFavorite](state, key) { 21 | return state.filterNot(item => item.get('key') === key) 22 | }, 23 | [updateFavorite](state, {key, data}) { 24 | return state.map(item => item.get('key') === key ? item.merge(data) : item) 25 | }, 26 | [reorderFavorites](state, {from, to}) { 27 | const target = state.get(from); 28 | return state.splice(from, 1).splice(to, 0, target); 29 | }, 30 | [reloadFavorites](state, data) { 31 | return fromJS(data) 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {combineReducers} from 'redux'; 4 | import {activeInstanceKey} from './activeInstanceKey' 5 | import {instances} from './instances' 6 | import {favorites} from './favorites' 7 | import {patterns} from './patterns' 8 | import {sizes} from './sizes' 9 | 10 | export default combineReducers({ 11 | patterns, 12 | favorites, 13 | instances, 14 | activeInstanceKey, 15 | sizes 16 | }); 17 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/instances.js: -------------------------------------------------------------------------------- 1 | import {handleActions} from 'Utils' 2 | import { 3 | createInstance, 4 | moveInstance, 5 | delInstance, 6 | updateConnectStatus, 7 | connectToRedis, 8 | disconnect 9 | } from 'Redux/actions' 10 | import {Map, List} from 'immutable' 11 | import {defaultInstanceKey} from './activeInstanceKey' 12 | 13 | function InstanceFactory({key, data}) { 14 | return Map(Object.assign({key, title: 'Medis'}, data)) 15 | } 16 | 17 | export const instances = handleActions(List([InstanceFactory({key: defaultInstanceKey})]), { 18 | [createInstance](state, data) { 19 | return state.push(InstanceFactory({data})) 20 | }, 21 | [moveInstance](state, {fromIndex, toIndex}) { 22 | const instance = state.get(fromIndex) 23 | return state.splice(fromIndex, 1).splice(toIndex, 0, instance) 24 | }, 25 | [delInstance](state, {targetIndex}) { 26 | return state.remove(targetIndex) 27 | }, 28 | [updateConnectStatus](state, {index, status}) { 29 | return state.setIn([index, 'connectStatus'], status) 30 | }, 31 | [disconnect](state, {index}) { 32 | const properties = ['connectStatus', 'redis', 'config', 'version'] 33 | return state.update(index, instance => ( 34 | instance.withMutations(map => { 35 | properties.forEach(key => map.remove(key)) 36 | map.set('title', 'Medis') 37 | }) 38 | )) 39 | }, 40 | [connectToRedis](state, {index, config, redis}) { 41 | const {name, sshHost, host, port} = config 42 | const remote = name ? `${name}/` : (sshHost ? `${sshHost}/` : '') 43 | const address = `${host}:${port}` 44 | const title = `${remote}${address}` 45 | const connectionKey = `${sshHost || ''}|${host}|${port}` 46 | const version = redis.serverInfo && redis.serverInfo.redis_version 47 | 48 | return state.update(index, instance => ( 49 | instance.merge({config, title, redis, version, connectionKey}).remove('connectStatus') 50 | )) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/patterns.js: -------------------------------------------------------------------------------- 1 | import {handleActions} from 'Utils' 2 | import { 3 | createPattern, 4 | removePattern, 5 | updatePattern, 6 | reloadPatterns 7 | } from 'Redux/actions' 8 | import {Patterns} from '../../storage' 9 | import {Map, List, fromJS} from 'immutable' 10 | 11 | function PatternFactory(data) { 12 | return Map(Object.assign({value: '*', name: '*'}, data)) 13 | } 14 | 15 | export const patterns = handleActions(fromJS(Patterns.get()), { 16 | [createPattern](state, {conn, key}) { 17 | return state.update(conn, List(), patterns => patterns.push(PatternFactory({key}))) 18 | }, 19 | [removePattern](state, {conn, index}) { 20 | return state.update(conn, List(), patterns => patterns.remove(index)) 21 | }, 22 | [updatePattern](state, {conn, index, data}) { 23 | return state.update(conn, List(), patterns => patterns.update(index, item => item.merge(data))) 24 | }, 25 | // [reorderPatterns](state, {conn, from, to}) { 26 | // return state.update(conn, List(), patterns => { 27 | // const target = patterns.get(from); 28 | // return patterns.splice(from, 1).splice(to, 0, target); 29 | // }) 30 | // }, 31 | [reloadPatterns](state, data) { 32 | return fromJS(data) 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /src/renderer/redux/reducers/sizes.js: -------------------------------------------------------------------------------- 1 | import {handleActions} from 'Utils' 2 | import { 3 | setSize 4 | } from 'Redux/actions' 5 | import {Sizes} from '../../storage' 6 | import {Map, List, fromJS} from 'immutable' 7 | 8 | export const sizes = handleActions(fromJS(Sizes.get()), { 9 | [setSize](state, {type, value}) { 10 | return state.set(`${type}BarWidth`, value) 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /src/renderer/redux/store.js: -------------------------------------------------------------------------------- 1 | import {compose, createStore, applyMiddleware} from 'redux' 2 | import persistEnhancer from './persistEnhancer' 3 | import {createThunkReplyMiddleware} from 'Redux/middlewares' 4 | import reducers from './reducers' 5 | 6 | const store = window.store = createStore( 7 | reducers, 8 | applyMiddleware(createThunkReplyMiddleware()) 9 | ) 10 | 11 | persistEnhancer(store) 12 | 13 | export default store 14 | -------------------------------------------------------------------------------- /src/renderer/storage/Favorites.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {ipcRenderer} from 'electron' 4 | 5 | export function get() { 6 | const data = localStorage.getItem('favorites') 7 | return data ? JSON.parse(data) : [] 8 | } 9 | 10 | export function set(favorites) { 11 | localStorage.setItem('favorites', JSON.stringify(favorites)) 12 | 13 | ipcRenderer.send('dispatch', 'reloadFavorites') 14 | return favorites 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/storage/Patterns.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import {ipcRenderer} from 'electron' 4 | 5 | export function get() { 6 | const data = localStorage.getItem('patternStore') 7 | return data ? JSON.parse(data) : {} 8 | } 9 | 10 | export function set(patterns) { 11 | localStorage.setItem('patternStore', JSON.stringify(patterns)) 12 | ipcRenderer.send('dispatch', 'reloadPatterns') 13 | return patterns 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/storage/Sizes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | export function get() { 4 | const data = localStorage.getItem('sizes') 5 | return data ? JSON.parse(data) : {} 6 | } 7 | 8 | export function set(sizes) { 9 | localStorage.setItem('sizes', JSON.stringify(sizes)) 10 | return sizes 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/storage/index.js: -------------------------------------------------------------------------------- 1 | import * as Favorites from './Favorites' 2 | import * as Patterns from './Patterns' 3 | import * as Sizes from './Sizes' 4 | 5 | export {Favorites, Patterns, Sizes} 6 | -------------------------------------------------------------------------------- /src/renderer/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import "photon"; 2 | @import "native"; 3 | 4 | html { 5 | background: #ececec; 6 | } 7 | 8 | ul { 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | .sidebar { 14 | background: #f5f5f4; 15 | display: flex; 16 | flex-direction: column; 17 | 18 | .nav-group { 19 | overflow: auto; 20 | flex: 1; 21 | } 22 | } 23 | 24 | .main { 25 | position: relative; 26 | flex: 1; 27 | } 28 | 29 | .nav-group { 30 | .sortable-placeholder { 31 | width: 100%; 32 | height: 26px; 33 | } 34 | } 35 | 36 | textarea { 37 | &:focus { 38 | outline: none; 39 | } 40 | } 41 | 42 | .Pane.vertical { 43 | height: 100%; 44 | display: flex; 45 | min-width: 0; 46 | } 47 | 48 | .fixedDataTableCellLayout_columnResizerContainer { 49 | border-right: 1px solid #ccc; 50 | } 51 | -------------------------------------------------------------------------------- /src/renderer/styles/native.scss: -------------------------------------------------------------------------------- 1 | .nt-box { 2 | box-sizing: border-box; 3 | position: relative; 4 | cursor: default; 5 | background-color: rgba(0, 0, 0, .04); 6 | border-width: 1px; 7 | border-style: solid; 8 | border-top-color: rgba(0, 0, 0, .07); 9 | border-left-color: rgba(0, 0, 0, .037); 10 | border-right-color: rgba(0, 0, 0, .037); 11 | border-bottom-color: rgba(0, 0, 0, .026); 12 | border-radius: 4px; 13 | padding: 23px 18px 22px 18px; 14 | } 15 | 16 | .nt-form-row, .form-control { 17 | padding: 5px 0; 18 | $label-width: 140px; 19 | label { 20 | float: left; 21 | width: $label-width; 22 | text-align: right; 23 | -webkit-user-select: text; 24 | } 25 | input, textarea, select { 26 | margin-left: 10px; 27 | } 28 | input[type="text"], input[type="number"], input[type="password"], select { 29 | width: 250px; 30 | -webkit-user-select: text; 31 | border-width: 1px; 32 | border-style: solid; 33 | border-color: #b0b0b0; 34 | border-left-color: #b1b1b1; 35 | border-right-color: #b1b1b1; 36 | box-shadow: inset 0 0 0 1px #f0f0f0; 37 | padding-top: 4px; 38 | padding-bottom: 4px; 39 | padding-left: 3.5px; 40 | padding-right: 3.5px; 41 | line-height: 14px; 42 | font-family: "San Francisco", "Helvetica Neue", "Lucida Grande", Arial, sans-serif; 43 | font-size: 13px; 44 | background: #fff; 45 | 46 | &:focus { 47 | outline: none; 48 | border-color: #6691d6; 49 | box-shadow: 0 0 0 2.5px #7ba7ec; 50 | border-radius: 1px; 51 | } 52 | 53 | &:placeholder { 54 | color: #c0c0c0; 55 | } 56 | 57 | &[disabled] { 58 | background: #f8f8f8; 59 | } 60 | } 61 | 62 | input[type="radio"], 63 | input[type="checkbox"] { 64 | line-height: normal; 65 | } 66 | 67 | &.nt-form-row--vertical { 68 | overflow: visible; 69 | label { 70 | float: none; 71 | display: block; 72 | text-align: left; 73 | } 74 | input[type="text"], input[type="password"], textarea { 75 | margin-left: 0; 76 | width: 100%; 77 | } 78 | } 79 | } 80 | 81 | .nt-button { 82 | cursor: default; 83 | background-color: #ffffff; 84 | outline: none; 85 | border-width: 1px; 86 | border-style: solid; 87 | border-radius: 3px; 88 | border-top-color: #c8c8c8; 89 | border-bottom-color: #acacac; 90 | border-left-color: #c2c2c2; 91 | border-right-color: #c2c2c2; 92 | box-shadow: 0 1px rgba(0, 0, 0, .039); 93 | padding-top: 0; 94 | padding-bottom: 0; 95 | padding-left: 13px; 96 | padding-right: 13px; 97 | line-height: 19px; 98 | font-size: 13px; 99 | 100 | &:active { 101 | background-image: -webkit-linear-gradient(top, #4c98fe 0%, #0564e3 100%); 102 | border-top-color: #247fff; 103 | border-bottom-color: #003ddb; 104 | border-left-color: #125eed; 105 | border-right-color: #125eed; 106 | color: rgba(255, 255, 255, .9); 107 | } 108 | 109 | margin-right: 10px; 110 | &:last-child { 111 | margin-right: 0; 112 | } 113 | } 114 | 115 | .nt-button--primary { 116 | background-image: -webkit-linear-gradient(top, #6ca5fc 0%, #076aff 100%); 117 | border-top-color: #4c93fa; 118 | border-bottom-color: #0261ff; 119 | border-left-color: #2d7efc; 120 | border-right-color: #2d7efc; 121 | color: rgba(255, 255, 255, .9); 122 | 123 | &:active { 124 | background-image: -webkit-linear-gradient(top, #4c98fe 0%, #0564e3 100%); 125 | border-top-color: #247fff; 126 | border-bottom-color: #003ddb; 127 | border-left-color: #125eed; 128 | border-right-color: #125eed; 129 | color: rgba(255, 255, 255, .9); 130 | } 131 | } 132 | 133 | .nt-button:focus { 134 | outline: none; 135 | border-color: #6691d6; 136 | box-shadow: 0 0 0 2.5px #7ba7ec; 137 | } 138 | 139 | .nt-button[disabled], .nt-button--disabled { 140 | background: #f1f1f1; 141 | border-color: #d0d0d0; 142 | color: #b1b1b1; 143 | } 144 | 145 | .nt-button-group { 146 | text-align: center; 147 | 148 | .nt-button { 149 | display: inline-block; 150 | } 151 | &.nt-button-group--pull-right { 152 | text-align: right; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/renderer/styles/photon.scss: -------------------------------------------------------------------------------- 1 | .tab-item-btn { 2 | width: 30px; 3 | flex: none; 4 | } 5 | 6 | .tab-group { 7 | background: #b3b1b3; 8 | } 9 | 10 | .nav-group-item:active { 11 | background-color: transparent !important; 12 | } 13 | 14 | .nav-group-item.sortable-ghost { 15 | opacity: 0; 16 | } 17 | 18 | .nav-group-item.active:active { 19 | background-color: #dcdfe1 !important; 20 | } 21 | 22 | .window { 23 | background: #ececec !important; 24 | } 25 | 26 | .toolbar-footer { 27 | button { 28 | width: 30px; 29 | border: none; 30 | border-right: 1px solid #c2c0c2; 31 | box-shadow: 1px 0px 0px 0px rgba(255, 255, 255, 0.4); 32 | background: transparent; 33 | font-size: 18px; 34 | line-height: 19px; 35 | opacity: 0.8; 36 | &:active { 37 | background-color: #dcdfe1; 38 | } 39 | } 40 | } 41 | 42 | button:focus { 43 | outline:0; 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/utils.ts: -------------------------------------------------------------------------------- 1 | import {createAction as _createAction} from 'redux-actions' 2 | 3 | export function handleActions(defaultState, handlers) { 4 | return function (state = defaultState, {type, payload}) { 5 | const handler = handlers[type] 6 | return handler ? handler(state, payload) : state 7 | } 8 | } 9 | 10 | export const getId = (function () { 11 | const ids = {} 12 | 13 | return function (item: string) { 14 | if (!ids[item]) { 15 | ids[item] = 0 16 | } 17 | 18 | return `${item}-${++ids[item] + (Math.random() * 100000 | 0)}` 19 | } 20 | }()) 21 | 22 | export function createAction(type: string, payloadCreator, metaCreator) { 23 | type = `$SOS_${type}` 24 | const actionCreator = _createAction(type, payloadCreator, metaCreator) 25 | const creator = (...args) => { 26 | const action = actionCreator(...args) 27 | if (typeof action.payload === 'function') { 28 | return Object.assign(action, {args}) 29 | } 30 | return action 31 | } 32 | 33 | return Object.assign(creator, { 34 | toString: actionCreator.toString, 35 | payload(payload) { 36 | return {type, payload} 37 | }, 38 | reply(args, result) { 39 | return {type, payload: {args, result}} 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/vendors/jquery.terminal/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This css file is part of jquery terminal 3 | * 4 | * Licensed under GNU LGPL Version 3 license 5 | * Copyright (c) 2011-2013 Jakub Jankiewicz 6 | * 7 | */ 8 | .terminal .terminal-output .format, .cmd .format, 9 | .cmd .prompt, .cmd .prompt div, .terminal .terminal-output div div{ 10 | display: inline-block; 11 | } 12 | .cmd .clipboard { 13 | position: absolute; 14 | bottom: 0; 15 | left: 0; 16 | opacity: 0.01; 17 | filter: alpha(opacity = 0.01); 18 | filter: progid:DXImageTransform.Microsoft.Alpha(opacity=0.01); 19 | width: 2px; 20 | } 21 | .cmd > .clipboard { 22 | position: fixed; 23 | } 24 | .terminal { 25 | padding: 10px; 26 | position: relative; 27 | overflow: hidden; 28 | } 29 | .cmd { 30 | padding: 0; 31 | margin: 0; 32 | height: 1.3em; 33 | /*margin-top: 3px; */ 34 | } 35 | .cmd .cursor.blink { 36 | -webkit-animation: blink 1s infinite steps(1, start); 37 | -moz-animation: blink 1s infinite steps(1, start); 38 | -ms-animation: blink 1s infinite steps(1, start); 39 | animation: blink 1s infinite steps(1, start); 40 | } 41 | @keyframes blink { 42 | 0%, 100% { 43 | background-color: #000; 44 | color: #aaa; 45 | } 46 | 50% { 47 | background-color: #bbb; /* not #aaa because it's seem there is Google Chrome bug */ 48 | color: #000; 49 | } 50 | } 51 | @-webkit-keyframes blink { 52 | 0%, 100% { 53 | background-color: #000; 54 | color: #aaa; 55 | } 56 | 50% { 57 | background-color: #bbb; 58 | color: #000; 59 | } 60 | } 61 | @-ms-keyframes blink { 62 | 0%, 100% { 63 | background-color: #000; 64 | color: #aaa; 65 | } 66 | 50% { 67 | background-color: #bbb; 68 | color: #000; 69 | } 70 | } 71 | @-moz-keyframes blink { 72 | 0%, 100% { 73 | background-color: #000; 74 | color: #aaa; 75 | } 76 | 50% { 77 | background-color: #bbb; 78 | color: #000; 79 | } 80 | } 81 | .terminal .terminal-output div div, .cmd .prompt { 82 | display: block; 83 | line-height: 18px; 84 | height: auto; 85 | } 86 | .cmd .prompt { 87 | float: left; 88 | } 89 | .terminal, .cmd { 90 | font-family: monospace; 91 | color: #eed1b3; 92 | background-color: #202020; 93 | font-size: 14px; 94 | line-height: 18px; 95 | } 96 | .terminal-output > div { 97 | /*padding-top: 3px;*/ 98 | min-height: 14px; 99 | } 100 | .terminal .terminal-output div span { 101 | display: inline-block; 102 | } 103 | .cmd span { 104 | float: left; 105 | /*display: inline-block; */ 106 | } 107 | .terminal .inverted, .cmd .inverted, .cmd .cursor.blink { 108 | background-color: #aaa; 109 | color: #000; 110 | } 111 | .terminal .terminal-output div div::-moz-selection, 112 | .terminal .terminal-output div span::-moz-selection, 113 | .terminal .terminal-output div div a::-moz-selection { 114 | background-color: #aaa; 115 | color: #000; 116 | } 117 | .terminal .terminal-output div div::selection, 118 | .terminal .terminal-output div div a::selection, 119 | .terminal .terminal-output div span::selection, 120 | .cmd > span::selection, 121 | .cmd .prompt span::selection { 122 | background-color: #aaa; 123 | color: #000; 124 | } 125 | .terminal .terminal-output div.error, .terminal .terminal-output div.error div { 126 | color: red; 127 | } 128 | .tilda { 129 | position: fixed; 130 | top: 0; 131 | left: 0; 132 | width: 100%; 133 | z-index: 1100; 134 | } 135 | .clear { 136 | clear: both; 137 | } 138 | .terminal a { 139 | color: #0F60FF; 140 | } 141 | .terminal a:hover { 142 | color: red; 143 | } 144 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/ConnectionSelectorContainer/Config/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import Immutable from 'immutable' 5 | import {remote} from 'electron' 6 | import fs from 'fs' 7 | 8 | require('./index.scss') 9 | 10 | class Config extends React.PureComponent { 11 | state = { 12 | data: new Immutable.Map() 13 | } 14 | 15 | getProp(property) { 16 | if (this.state.data.has(property)) { 17 | return this.state.data.get(property) 18 | } 19 | return (this.props.favorite ? this.props.favorite.get(property) : '') || '' 20 | } 21 | 22 | setProp(property, value) { 23 | this.setState({ 24 | data: typeof property === 'string' ? this.state.data.set(property, value) : this.state.data.merge(property), 25 | changed: Boolean(this.props.favorite) 26 | }) 27 | } 28 | 29 | componentWillReceiveProps(nextProps) { 30 | if (!this.props.connect && nextProps.connect) { 31 | this.connect() 32 | } 33 | if (this.props.favorite || nextProps.favorite) { 34 | const leaving = !this.props.favorite || !nextProps.favorite || 35 | (this.props.favorite.get('key') !== nextProps.favorite.get('key')) 36 | if (leaving) { 37 | this.setState({changed: false, data: new Immutable.Map()}) 38 | } 39 | } 40 | } 41 | 42 | connect() { 43 | const {favorite, connectToRedis} = this.props 44 | const data = this.state.data 45 | const config = favorite ? favorite.merge(data).toJS() : data.toJS() 46 | config.host = config.host || 'localhost' 47 | config.port = config.port || '6379' 48 | config.sshPort = config.sshPort || '22' 49 | connectToRedis(config) 50 | this.save() 51 | } 52 | 53 | handleChange(property, e) { 54 | let value = e.target.value 55 | if (property === 'ssh' || property === 'ssl') { 56 | value = e.target.checked 57 | } 58 | this.setProp(property, value) 59 | } 60 | 61 | duplicate() { 62 | if (this.props.favorite) { 63 | const data = Object.assign(this.props.favorite.toJS(), this.state.data.toJS()) 64 | delete data.key 65 | this.props.onDuplicate(data) 66 | } else { 67 | const data = this.state.data.toJS() 68 | data.name = 'Quick Connect' 69 | this.props.onDuplicate(data) 70 | } 71 | } 72 | 73 | save() { 74 | if (this.props.favorite && this.state.changed) { 75 | this.props.onSave(this.state.data.toJS()) 76 | this.setState({changed: false, data: new Immutable.Map()}) 77 | } 78 | } 79 | 80 | renderCertInput(label, id) { 81 | return (
82 | 83 | 90 |
) 105 | } 106 | 107 | render() { 108 | return (
109 |
110 |
111 | 112 | 113 |
114 |
115 | 116 | 117 |
118 |
119 | 120 | 121 |
122 |
123 | 124 | 125 |
126 |
127 | 128 | 129 |
130 |
131 | {this.renderCertInput('Private Key', 'tlskey')} 132 | {this.renderCertInput('Certificate', 'tlscert')} 133 | {this.renderCertInput('CA', 'tlsca')} 134 |
135 |
136 | 137 | 138 |
139 |
140 |
141 | 142 | 143 |
144 |
145 | 146 | 147 |
148 |
149 | 150 | 158 |
181 |
-1 ? 'block' : 'none'}}> 182 | 183 | 184 |
185 |
186 | 187 | 188 |
189 |
190 |
191 |
192 | 197 | 204 | 209 |
210 |
) 211 | } 212 | } 213 | 214 | export default Config 215 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/ConnectionSelectorContainer/Config/index.scss: -------------------------------------------------------------------------------- 1 | #sshPassword { 2 | padding-right: 32px; 3 | } 4 | 5 | .ssh-key { 6 | height: 22px; 7 | line-height: 22px; 8 | padding: 0; 9 | text-align: center; 10 | width: 30px; 11 | position: relative; 12 | top: 0; 13 | left: -30px; 14 | border-top: 0; 15 | border-bottom: 0; 16 | border-right: 1px solid #b1b1b1; 17 | border-left: 1px solid #b1b1b1; 18 | background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%); 19 | 20 | &.is-active { 21 | color: #388af8; 22 | } 23 | 24 | &:active { 25 | background: linear-gradient(to bottom, #c2c2c2 0%, #b6b5b6 100%); 26 | color: #388af8; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/ConnectionSelectorContainer/Favorite.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import Sortable from 'sortablejs' 5 | 6 | class Favorite extends React.PureComponent { 7 | constructor() { 8 | super() 9 | this.state = { 10 | activeKey: null 11 | } 12 | this._updateSortableKey() 13 | } 14 | 15 | _updateSortableKey() { 16 | this.sortableKey = `sortable-${Math.round(Math.random() * 10000)}` 17 | } 18 | 19 | _bindSortable() { 20 | const {reorderFavorites} = this.props 21 | 22 | this.sortable = Sortable.create(this.refs.sortable, { 23 | animation: 100, 24 | onStart: evt => { 25 | this.nextSibling = evt.item.nextElementSibling 26 | }, 27 | onAdd: () => { 28 | this._updateSortableKey() 29 | }, 30 | onUpdate: evt => { 31 | this._updateSortableKey() 32 | reorderFavorites({from: evt.oldIndex, to: evt.newIndex}) 33 | } 34 | }) 35 | } 36 | 37 | componentDidMount() { 38 | this._bindSortable() 39 | } 40 | 41 | componentDidUpdate() { 42 | this._bindSortable() 43 | } 44 | 45 | onClick(index, evt) { 46 | evt.preventDefault() 47 | this.selectIndex(index) 48 | } 49 | 50 | onDoubleClick(index, evt) { 51 | evt.preventDefault() 52 | this.selectIndex(index, true) 53 | } 54 | 55 | selectIndex(index, connect) { 56 | this.select(index === -1 ? null : this.props.favorites.get(index), connect) 57 | } 58 | 59 | select(favorite, connect) { 60 | const activeKey = favorite ? favorite.get('key') : null 61 | this.setState({activeKey}) 62 | if (connect) { 63 | this.props.onRequireConnecting(activeKey) 64 | } else { 65 | this.props.onSelect(activeKey) 66 | } 67 | } 68 | 69 | render() { 70 | return (
71 | 96 |
97 | 104 | 123 |
124 |
) 125 | } 126 | 127 | componentWillUnmount() { 128 | this.sortable.destroy() 129 | } 130 | } 131 | 132 | export default Favorite 133 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/ConnectionSelectorContainer/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {PureComponent} from 'react' 4 | import {connect} from 'react-redux' 5 | import Favorite from './Favorite' 6 | import Config from './Config' 7 | import {connectToRedis} from 'Redux/actions' 8 | import {removeFavorite, updateFavorite, createFavorite, reorderFavorites} from 'Redux/actions' 9 | 10 | class ConnectionSelector extends PureComponent { 11 | state = {connect: false, key: null} 12 | 13 | handleSelectFavorite(connect, key) { 14 | this.setState({connect, key}) 15 | } 16 | 17 | render() { 18 | const selectedFavorite = this.state.key && this.props.favorites.find(item => item.get('key') === this.state.key) 19 | return (
20 | 31 |
32 | { 38 | this.props.updateFavorite(selectedFavorite.get('key'), data) 39 | }} 40 | onDuplicate={this.props.createFavorite} 41 | /> 42 |
43 |
) 44 | } 45 | } 46 | 47 | function mapStateToProps(state, {instance}) { 48 | return { 49 | favorites: state.favorites, 50 | connectStatus: instance.get('connectStatus') 51 | } 52 | } 53 | const mapDispatchToProps = { 54 | updateFavorite, 55 | createFavorite, 56 | connectToRedis, 57 | reorderFavorites, 58 | removeFavorite 59 | } 60 | 61 | export default connect(mapStateToProps, mapDispatchToProps)(ConnectionSelector) 62 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/AddButton/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react' 2 | 3 | require('./index.scss') 4 | 5 | function AddButton({title, reload, onReload, onClick}) { 6 | return (
7 | {title} 8 | {reload && } 9 | + 10 |
) 11 | } 12 | 13 | export default memo(AddButton) 14 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/AddButton/index.scss: -------------------------------------------------------------------------------- 1 | .AddButton { 2 | position: relative; 3 | 4 | span.plus, span.reload { 5 | position: absolute; 6 | right: 4px; 7 | top: 4px; 8 | width: 16px; 9 | height: 16px; 10 | line-height: 13px; 11 | border: 1px solid #ccc; 12 | border-radius: 2px; 13 | background-image: linear-gradient(#fff, #efefef); 14 | text-align: center; 15 | font-weight: normal; 16 | color: #888; 17 | 18 | &:hover { 19 | background: #fff; 20 | } 21 | 22 | &:active { 23 | background: #efefef; 24 | } 25 | } 26 | 27 | span.reload { 28 | right: 24px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/Config/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import clone from 'lodash.clone' 5 | 6 | require('./index.scss') 7 | 8 | class Config extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | this.writeableFields = [ 12 | 'dbfilename', 13 | 'requirepass', 14 | 'masterauth', 15 | 'maxclients', 16 | 'appendonly', 17 | 'save', 18 | 'dir', 19 | 'client-output-buffer-limit', 20 | 'notify-keyspace-events', 21 | 'rdbcompression', 22 | 'repl-disable-tcp-nodelay', 23 | 'repl-diskless-sync', 24 | 'cluster-require-full-coverage', 25 | 'aof-rewrite-incremental-fsync', 26 | 'aof-load-truncated', 27 | 'slave-serve-stale-data', 28 | 'slave-read-only', 29 | 'activerehashing', 30 | 'stop-writes-on-bgsave-error', 31 | 'lazyfree-lazy-eviction', 32 | 'lazyfree-lazy-expire', 33 | 'lazyfree-lazy-server-del', 34 | 'slave-lazy-flush', 35 | 'tcp-keepalive', 36 | 'maxmemory-samples', 37 | 'timeout', 38 | 'auto-aof-rewrite-percentage', 39 | 'auto-aof-rewrite-min-size', 40 | 'hash-max-ziplist-entries', 41 | 'hash-max-ziplist-value', 42 | 'list-max-ziplist-entries', 43 | 'list-max-ziplist-value', 44 | 'list-max-ziplist-size', 45 | 'list-compress-depth', 46 | 'set-max-intset-entries', 47 | 'zset-max-ziplist-entries', 48 | 'zset-max-ziplist-value', 49 | 'hll-sparse-max-bytes', 50 | 'lua-time-limit', 51 | 'slowlog-log-slower-than', 52 | 'slowlog-max-len', 53 | 'latency-monitor-threshold', 54 | 'repl-ping-slave-period', 55 | 'repl-timeout', 56 | 'repl-backlog-ttl', 57 | 'repl-diskless-sync-delay', 58 | 'slave-priority', 59 | 'min-slaves-to-write', 60 | 'min-slaves-max-lag', 61 | 'cluster-node-timeout', 62 | 'cluster-migration-barrier', 63 | 'cluster-slave-validity-factor', 64 | 'hz', 65 | 'watchdog-period', 66 | 'maxmemory', 67 | 'repl-backlog-size', 68 | 'loglevel', 69 | 'maxmemory-policy', 70 | 'appendfsync' 71 | ] 72 | this.groups = [ 73 | { 74 | name: 'General', 75 | configs: [ 76 | {name: 'port', type: 'number'}, 77 | {name: 'bind'}, 78 | {name: 'unixsocket'}, 79 | {name: 'unixsocketperm', type: 'number'}, 80 | {name: 'daemonize', type: 'boolean'}, 81 | {name: 'pidfile'}, 82 | {name: 'tcp-backlog', type: 'number'}, 83 | {name: 'tcp-keepalive', type: 'number'}, 84 | {name: 'timeout', type: 'number'}, 85 | {name: 'databases', type: 'number'} 86 | ] 87 | }, 88 | { 89 | name: 'Logging', 90 | configs: [ 91 | {name: 'loglevel', type: ['debug', 'verbose', 'notice', 'warning']}, 92 | {name: 'logfile'}, 93 | {name: 'syslog-enabled', type: 'boolean'}, 94 | {name: 'syslog-ident'}, 95 | {name: 'syslog-facility'} 96 | ] 97 | }, 98 | { 99 | name: 'Snap Shotting', 100 | configs: [ 101 | {name: 'dbfilename'}, 102 | {name: 'dir'}, 103 | {name: 'save'}, 104 | {name: 'stop-writes-on-bgsave-error', type: 'boolean'}, 105 | {name: 'rdbcompression', type: 'boolean'}, 106 | {name: 'rdbchecksum', type: 'boolean'} 107 | ] 108 | }, 109 | { 110 | name: 'Replication', 111 | configs: [ 112 | {name: 'slaveof'}, 113 | {name: 'masterauth'}, 114 | {name: 'slave-serve-stale-data', type: 'boolean'}, 115 | {name: 'slave-read-only', type: 'boolean'}, 116 | {name: 'repl-diskless-sync', type: 'boolean'}, 117 | {name: 'repl-diskless-sync-delay', type: 'number'}, 118 | {name: 'repl-ping-slave-period', type: 'number'}, 119 | {name: 'repl-timeout', type: 'number'}, 120 | {name: 'repl-disable-tcp-nodelay', type: 'boolean'}, 121 | {name: 'repl-backlog-size'}, 122 | {name: 'repl-backlog-ttl', type: 'number'}, 123 | {name: 'slave-priority', type: 'number'}, 124 | {name: 'min-slaves-to-write', type: 'number'}, 125 | {name: 'min-slaves-max-lag', type: 'number'} 126 | ] 127 | }, 128 | { 129 | name: 'Security', 130 | configs: [ 131 | {name: 'requirepass'}, 132 | {name: 'rename-command'} 133 | ] 134 | }, 135 | { 136 | name: 'Limits', 137 | configs: [ 138 | {name: 'maxclients'}, 139 | {name: 'maxmemory'}, 140 | {name: 'maxmemory-policy', type: ['noeviction', 'volatile-lru', 'allkeys-lru', 'volatile-random', 'allkeys-random', 'volatile-ttl']}, 141 | {name: 'maxmemory-samples', type: 'number'} 142 | ] 143 | }, 144 | { 145 | name: 'Append Only Mode', 146 | configs: [ 147 | {name: 'appendonly', type: 'boolean'}, 148 | {name: 'appendfilename'}, 149 | {name: 'appendfsync', type: ['everysec', 'always', 'no']}, 150 | {name: 'no-appendfsync-on-rewrite', type: 'boolean'}, 151 | {name: 'auto-aof-rewrite-percentage', type: 'number'}, 152 | {name: 'auto-aof-rewrite-min-size'}, 153 | {name: 'aof-load-truncated', type: 'number'} 154 | ] 155 | }, 156 | { 157 | name: 'LUA Scripting', 158 | configs: [ 159 | {name: 'lua-time-limit', type: 'number'} 160 | ] 161 | }, 162 | { 163 | name: 'Cluster', 164 | configs: [ 165 | {name: 'cluster-enabled', type: 'boolean'}, 166 | {name: 'cluster-config-file'}, 167 | {name: 'cluster-node-timeout', type: 'number'}, 168 | {name: 'cluster-slave-validity-factor', type: 'nubmer'}, 169 | {name: 'cluster-migration-barrier', type: 'number'}, 170 | {name: 'cluster-require-full-coverage', type: 'boolean'} 171 | ] 172 | }, 173 | { 174 | name: 'Slow Log', 175 | configs: [ 176 | {name: 'slowlog-log-slower-than', type: 'number'}, 177 | {name: 'slowlog-max-len', type: 'number'} 178 | ] 179 | }, 180 | { 181 | name: 'Latency Monitor', 182 | configs: [ 183 | {name: 'latency-monitor-threshold', type: 'number'} 184 | ] 185 | }, 186 | { 187 | name: 'Event Notification', 188 | configs: [ 189 | {name: 'notify-keyspace-events'} 190 | ] 191 | }, 192 | { 193 | name: 'Advanced Config', 194 | configs: [ 195 | {name: 'hash-max-ziplist-entries', type: 'number'}, 196 | {name: 'hash-max-ziplist-value', type: 'number'}, 197 | {name: 'list-max-ziplist-entries', type: 'number'}, 198 | {name: 'list-max-ziplist-value', type: 'number'}, 199 | {name: 'set-max-intset-entries', type: 'number'}, 200 | {name: 'zset-max-ziplist-entries', type: 'number'}, 201 | {name: 'zset-max-ziplist-value', type: 'number'}, 202 | {name: 'hll-sparse-max-bytes', type: 'number'}, 203 | {name: 'activerehashing', type: 'boolean'}, 204 | {name: 'client-output-buffer-limit'}, 205 | {name: 'hz', type: 'number'}, 206 | {name: 'aof-rewrite-incremental-fsync', type: 'boolean'} 207 | ] 208 | } 209 | ] 210 | this.state = { 211 | groups: [], 212 | unsavedRewrites: {}, 213 | unsavedConfigs: {} 214 | } 215 | this.load() 216 | } 217 | 218 | load() { 219 | this.props.redis.config('get', '*').then(config => { 220 | const configs = {} 221 | 222 | for (let i = 0; i < config.length - 1; i += 2) { 223 | configs[config[i]] = config[i + 1] 224 | } 225 | 226 | const groups = clone(this.groups, true).map(g => { 227 | g.configs = g.configs.map(c => { 228 | if (typeof configs[c.name] !== 'undefined') { 229 | c.value = configs[c.name] 230 | delete configs[c.name] 231 | } 232 | return c 233 | }).filter(c => typeof c.value !== 'undefined') 234 | return g 235 | }).filter(g => g.configs.length) 236 | 237 | if (Object.keys(configs).length) { 238 | groups.push({name: 'Other', configs: Object.keys(configs).map(key => { 239 | return { 240 | name: key, 241 | value: configs[key] 242 | } 243 | })}) 244 | } 245 | 246 | this.setState({ 247 | groups, 248 | unsavedConfigs: {}, 249 | unsavedRewrites: {} 250 | }) 251 | }) 252 | } 253 | 254 | componentWillUnmount() { 255 | this.props.redis.removeAllListeners('select', this.onSelectBinded) 256 | } 257 | 258 | renderGroup(group) { 259 | return (
263 |

{group.name}

264 | { group.configs.map(this.renderConfig, this) } 265 |
) 266 | } 267 | 268 | change({name, value}) { 269 | this.state.unsavedConfigs[name] = value 270 | this.state.unsavedRewrites[name] = value 271 | this.setState({ 272 | groups: this.state.groups, 273 | unsavedConfigs: this.state.unsavedConfigs, 274 | unsavedRewrites: this.state.unsavedRewrites 275 | }) 276 | } 277 | 278 | renderConfig(config) { 279 | let input 280 | const props = {readOnly: this.writeableFields.indexOf(config.name) === -1} 281 | props.disabled = props.readOnly 282 | if (config.type === 'boolean' && 283 | (config.value === 'yes' || config.value === 'no')) { 284 | input = ( { 286 | config.value = e.target.checked ? 'yes' : 'no' 287 | this.change(config) 288 | }} {...props} 289 | />) 290 | } else if (config.type === 'number' && String(parseInt(config.value, 10)) === config.value) { 291 | input = ( { 293 | config.value = e.target.value 294 | this.change(config) 295 | }} {...props} 296 | />) 297 | } else if (Array.isArray(config.type) && config.type.indexOf(config.value) !== -1) { 298 | input = () 306 | } else { 307 | input = ( { 309 | config.value = e.target.value 310 | this.change(config) 311 | }} {...props} 312 | />) 313 | } 314 | return (
318 | 319 | { input } 320 |
{config.description}
321 |
) 322 | } 323 | 324 | isChanged(rewrite) { 325 | return Object.keys(this.state[rewrite ? 'unsavedRewrites' : 'unsavedConfigs']).length > 0 326 | } 327 | 328 | handleReload() { 329 | if (this.isChanged()) { 330 | showModal({ 331 | title: 'Reload config?', 332 | content: 'Are you sure you want to reload the config? Your changes will be lost if you reload the config.', 333 | button: 'Reload' 334 | }).then(() => { 335 | this.load() 336 | }) 337 | } else { 338 | this.load() 339 | } 340 | } 341 | 342 | handleSave() { 343 | showModal({ 344 | title: 'Save the changes', 345 | content: 'Are you sure you want to apply the changes and save the changes to the config file?', 346 | button: 'Save' 347 | }).then(() => { 348 | return this.handleApply(true) 349 | }).then(res => { 350 | return this.props.redis.config('rewrite') 351 | }).then(res => { 352 | this.setState({unsavedRewrites: {}}) 353 | }).catch(err => { 354 | alert(err.message) 355 | }) 356 | } 357 | 358 | handleApply(embed) { 359 | const confirm = embed ? Promise.resolve(1) : showModal({ 360 | title: 'Apply the changes', 361 | content: 'Are you sure you want to apply the changes? The changes are only valid for the current session and will be lost when Redis restarts.', 362 | button: 'Apply' 363 | }) 364 | return confirm.then(() => { 365 | const pipeline = this.props.redis.pipeline() 366 | const unsavedConfigs = this.state.unsavedConfigs 367 | Object.keys(unsavedConfigs).forEach(config => { 368 | pipeline.config('set', config, unsavedConfigs[config]) 369 | }) 370 | return pipeline.exec() 371 | }).then(res => { 372 | this.setState({unsavedConfigs: {}}) 373 | }) 374 | } 375 | 376 | render() { 377 | return (
378 |
379 |
380 | { 381 | this.state.groups.map(this.renderGroup, this) 382 | } 383 |
384 |
385 | 390 | 396 | 404 |
405 |
406 |
) 407 | } 408 | } 409 | 410 | export default Config 411 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/Config/index.scss: -------------------------------------------------------------------------------- 1 | .Config { 2 | overflow: auto; 3 | .wrapper { 4 | width: 430px; 5 | margin: 0 auto; 6 | .nt-form-row label { 7 | width: 170px; 8 | } 9 | } 10 | 11 | h3 { 12 | font-size: 20px; 13 | } 14 | 15 | .nt-button-group { 16 | margin: 20px 0; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/Footer.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import humanFormat from 'human-format' 5 | 6 | const timeScale = new humanFormat.Scale({ 7 | ms: 1, 8 | s: 1000, 9 | min: 60000, 10 | h: 3600000, 11 | d: 86400000 12 | }) 13 | 14 | const initState = { 15 | ttl: null, 16 | encoding: null, 17 | size: null 18 | } 19 | 20 | class Footer extends React.PureComponent { 21 | state = initState 22 | 23 | init(keyName, keyType) { 24 | if (!keyType && keyType !== 'none') { 25 | this.setState(initState) 26 | return 27 | } 28 | const pipeline = this.props.redis.pipeline() 29 | pipeline.pttl(keyName) 30 | pipeline.object('ENCODING', keyName) 31 | 32 | let sizeUnit = 'Members' 33 | switch (keyType) { 34 | case 'string': pipeline.strlen(keyName); sizeUnit = 'Bytes'; break 35 | case 'hash': pipeline.hlen(keyName); break 36 | case 'list': pipeline.llen(keyName); break 37 | case 'set': pipeline.scard(keyName); break 38 | case 'zset': pipeline.zcard(keyName); break 39 | } 40 | 41 | pipeline.exec((err, [[err1, pttl], [err2, encoding], res3]) => { 42 | this.setState({ 43 | encoding: encoding ? `Encoding: ${encoding}` : '', 44 | ttl: pttl >= 0 ? `TTL: ${humanFormat(pttl, {scale: timeScale}).replace(' ', '')}` : null, 45 | size: (res3 && res3[1]) ? `${sizeUnit}: ${res3[1]}` : null 46 | }) 47 | }) 48 | } 49 | 50 | componentDidMount() { 51 | this.init(this.props.keyName, this.props.keyType) 52 | } 53 | 54 | componentWillReceiveProps(nextProps) { 55 | if (nextProps.keyName !== this.props.keyName || 56 | nextProps.keyType !== this.props.keyType || 57 | nextProps.version !== this.props.version) { 58 | this.init(nextProps.keyName, nextProps.keyType) 59 | } 60 | } 61 | 62 | render() { 63 | const desc = ['size', 'encoding', 'ttl'] 64 | .map(key => ({key, value: this.state[key]})) 65 | .filter(item => typeof item.value === 'string') 66 | return (
67 | { 68 | desc.map(({key, value}) => {value}) 72 | } 73 |
) 74 | } 75 | } 76 | 77 | export default Footer 78 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/Editor/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import Codemirror from 'medis-react-codemirror' 6 | require('codemirror/mode/javascript/javascript') 7 | require('codemirror/addon/lint/json-lint') 8 | require('codemirror/addon/lint/lint') 9 | require('codemirror/addon/selection/active-line') 10 | require('codemirror/addon/edit/closebrackets') 11 | require('codemirror/addon/edit/matchbrackets') 12 | require('codemirror/addon/search/search') 13 | require('codemirror/addon/search/searchcursor') 14 | require('codemirror/addon/search/jump-to-line') 15 | require('codemirror/addon/dialog/dialog') 16 | require('codemirror/addon/dialog/dialog.css') 17 | import jsonlint from 'jsonlint' 18 | window.jsonlint = jsonlint.parser 19 | require('codemirror/lib/codemirror.css') 20 | require('codemirror/addon/lint/lint.css') 21 | const msgpack = require('msgpack5')() 22 | 23 | require('./index.scss') 24 | 25 | class Editor extends React.PureComponent { 26 | constructor() { 27 | super() 28 | 29 | this.resizeObserver = new ResizeObserver(() => { 30 | this.updateLayout() 31 | }) 32 | 33 | this.state = { 34 | currentMode: '', 35 | wrapping: true, 36 | changed: false, 37 | modes: { 38 | raw: false, 39 | json: false, 40 | messagepack: false 41 | } 42 | } 43 | } 44 | 45 | updateLayout() { 46 | const {wrapSelector, codemirror} = this.refs 47 | 48 | const $this = $(ReactDOM.findDOMNode(this)) 49 | if ($this.width() < 372) { 50 | $(ReactDOM.findDOMNode(wrapSelector)).hide() 51 | } else { 52 | $(ReactDOM.findDOMNode(wrapSelector)).show() 53 | } 54 | if (codemirror) { 55 | codemirror.getCodeMirror().refresh() 56 | } 57 | } 58 | 59 | componentDidMount() { 60 | this.init(this.props.buffer) 61 | this.resizeObserver.observe(ReactDOM.findDOMNode(this)) 62 | } 63 | 64 | componentWillUnmount() { 65 | this.resizeObserver.disconnect() 66 | } 67 | 68 | componentWillReceiveProps(nextProps) { 69 | if (nextProps.buffer !== this.props.buffer) { 70 | this.init(nextProps.buffer) 71 | } 72 | } 73 | 74 | init(buffer) { 75 | if (!buffer) { 76 | this.setState({currentMode: '', changed: false}) 77 | return 78 | } 79 | const content = buffer.toString() 80 | const modes = {} 81 | modes.raw = content 82 | modes.json = tryFormatJSON(content, true) 83 | modes.messagepack = modes.json ? false : tryFormatMessagepack(buffer, true) 84 | let currentMode = 'raw' 85 | if (modes.messagepack) { 86 | currentMode = 'messagepack' 87 | } else if (modes.json) { 88 | currentMode = 'json' 89 | } 90 | this.setState({modes, currentMode, changed: false}, () => { 91 | this.updateLayout() 92 | }) 93 | } 94 | 95 | save() { 96 | let content = this.state.modes.raw 97 | if (this.state.currentMode === 'json') { 98 | content = tryFormatJSON(this.state.modes.json) 99 | if (!content) { 100 | alert('The json is invalid. Please check again.') 101 | return 102 | } 103 | } else if (this.state.currentMode === 'messagepack') { 104 | content = tryFormatMessagepack(this.state.modes.messagepack) 105 | if (!content) { 106 | alert('The json is invalid. Please check again.') 107 | return 108 | } 109 | content = msgpack.encode(JSON.parse(content)) 110 | } 111 | this.props.onSave(content, err => { 112 | if (err) { 113 | alert(`Redis save failed: ${err.message}`) 114 | } else { 115 | this.init(typeof content === 'string' ? Buffer.from(content) : content) 116 | } 117 | }) 118 | } 119 | 120 | updateContent(mode, content) { 121 | if (this.state.modes[mode] !== content) { 122 | this.state.modes[mode] = content 123 | this.setState({modes: this.state.modes, changed: true}) 124 | } 125 | } 126 | 127 | updateMode(evt) { 128 | const newMode = evt.target.value 129 | this.setState({currentMode: newMode}) 130 | } 131 | 132 | focus() { 133 | const codemirror = this.refs.codemirror 134 | if (codemirror) { 135 | const node = ReactDOM.findDOMNode(codemirror) 136 | if (node) { 137 | node.focus() 138 | } 139 | } 140 | } 141 | 142 | handleKeyDown(evt) { 143 | if (!evt.ctrlKey && evt.metaKey && evt.keyCode === 83) { 144 | this.save() 145 | evt.preventDefault() 146 | evt.stopPropagation() 147 | } 148 | } 149 | 150 | render() { 151 | let viewer 152 | if (this.state.currentMode === 'raw') { 153 | viewer = () 166 | } else if (this.state.currentMode === 'json') { 167 | viewer = () 188 | } else if (this.state.currentMode === 'messagepack') { 189 | viewer = () 210 | } else { 211 | viewer =
212 | } 213 | return (
218 | { viewer } 219 |
222 | 230 | 239 | 244 |
245 |
) 246 | } 247 | } 248 | 249 | export default Editor 250 | 251 | function tryFormatJSON(jsonString, beautify) { 252 | try { 253 | const o = JSON.parse(jsonString) 254 | if (o && typeof o === 'object' && o !== null) { 255 | if (beautify) { 256 | return JSON.stringify(o, null, '\t') 257 | } 258 | return JSON.stringify(o) 259 | } 260 | } catch (e) { /**/ } 261 | 262 | return false 263 | } 264 | 265 | function tryFormatMessagepack(buffer, beautify) { 266 | try { 267 | let o 268 | if (typeof buffer === 'string') { 269 | o = JSON.parse(buffer) 270 | } else { 271 | o = msgpack.decode(buffer) 272 | } 273 | if (o && typeof o === 'object' && o !== null) { 274 | if (beautify) { 275 | return JSON.stringify(o, null, '\t') 276 | } 277 | return JSON.stringify(o) 278 | } 279 | } catch (e) { /**/ } 280 | 281 | return false 282 | } 283 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/Editor/index.scss: -------------------------------------------------------------------------------- 1 | .Editor { 2 | position: relative; 3 | min-width: 0; 4 | textarea { 5 | border: none; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .CodeMirror { 11 | height: auto; 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | font-family: Consolas, monospace; 16 | } 17 | 18 | .ReactCodeMirror { 19 | position: relative; 20 | flex: 1; 21 | display: flex; 22 | width: 100%; 23 | overflow-y: auto; 24 | 25 | &:before { 26 | content: ''; 27 | position: absolute; 28 | left: 0; 29 | top: 0; 30 | width: 45px; 31 | z-index: 1; 32 | background: #f7f7f7; 33 | border-right: 1px solid #ddd; 34 | height: 100%; 35 | } 36 | } 37 | 38 | .operation-pannel { 39 | z-index: 99; 40 | } 41 | 42 | .mode-selector { 43 | position: absolute; 44 | bottom: 10px; 45 | right: 10px; 46 | } 47 | 48 | .wrap-selector { 49 | position: absolute; 50 | bottom: 5px; 51 | right: 120px; 52 | padding: 0 10px; 53 | span { 54 | margin-left: 4px; 55 | } 56 | } 57 | 58 | button { 59 | position: absolute; 60 | bottom: 10px; 61 | left: 54px; 62 | z-index: 99; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/HashContent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import BaseContent from '.' 5 | import SplitPane from 'react-split-pane' 6 | import {Table, Column} from 'fixed-data-table-contextmenu' 7 | import Editor from './Editor' 8 | import AddButton from '../../../AddButton' 9 | import ContentEditable from '../../../ContentEditable' 10 | import ReactDOM from 'react-dom' 11 | import {clipboard, remote} from 'electron' 12 | 13 | class HashContent extends BaseContent { 14 | save(value, callback) { 15 | if (typeof this.state.selectedIndex === 'number') { 16 | const [key] = this.state.members[this.state.selectedIndex] 17 | this.state.members[this.state.selectedIndex][1] = Buffer.from(value) 18 | this.setState({members: this.state.members}) 19 | this.props.redis.hset(this.state.keyName, key, value, (err, res) => { 20 | this.props.onKeyContentChange() 21 | callback(err, res) 22 | }) 23 | } else { 24 | alert('Please wait for data been loaded before saving.') 25 | } 26 | } 27 | 28 | load(index) { 29 | if (!super.load(index)) { 30 | return 31 | } 32 | const count = Number(this.cursor) ? 10000 : 500 33 | this.props.redis.hscanBuffer(this.state.keyName, this.cursor, 'MATCH', '*', 'COUNT', count, (_, [cursor, result]) => { 34 | for (let i = 0; i < result.length - 1; i += 2) { 35 | this.state.members.push([result[i].toString(), result[i + 1]]) 36 | } 37 | this.cursor = cursor 38 | this.setState({members: this.state.members}, () => { 39 | if (typeof this.state.selectedIndex !== 'number' && this.state.members.length) { 40 | this.handleSelect(null, 0) 41 | } 42 | this.loading = false 43 | if (this.state.members.length - 1 < this.maxRow && Number(cursor)) { 44 | this.load() 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | handleSelect(evt, selectedIndex) { 51 | const item = this.state.members[selectedIndex] 52 | if (item) { 53 | this.setState({selectedIndex, content: item[1]}) 54 | } 55 | } 56 | 57 | handleKeyDown(e) { 58 | if (typeof this.state.selectedIndex === 'number' && typeof this.state.editableIndex !== 'number') { 59 | if (e.keyCode === 8) { 60 | this.deleteSelectedMember() 61 | return false 62 | } 63 | if (e.keyCode === 38) { 64 | if (this.state.selectedIndex > 0) { 65 | this.handleSelect(null, this.state.selectedIndex - 1) 66 | } 67 | return false 68 | } 69 | if (e.keyCode === 40) { 70 | if (this.state.selectedIndex < this.state.members.length - 1) { 71 | this.handleSelect(null, this.state.selectedIndex + 1) 72 | } 73 | return false 74 | } 75 | } 76 | } 77 | 78 | deleteSelectedMember() { 79 | if (typeof this.state.selectedIndex !== 'number') { 80 | return 81 | } 82 | showModal({ 83 | title: 'Delete selected item?', 84 | button: 'Delete', 85 | content: 'Are you sure you want to delete the selected item? This action cannot be undone.' 86 | }).then(() => { 87 | const members = this.state.members 88 | const deleted = members.splice(this.state.selectedIndex, 1) 89 | if (deleted.length) { 90 | this.props.redis.hdel(this.state.keyName, deleted[0]) 91 | if (this.state.selectedIndex >= members.length - 1) { 92 | this.state.selectedIndex -= 1 93 | } 94 | this.setState({members, length: this.state.length - 1}, () => { 95 | this.props.onKeyContentChange() 96 | this.handleSelect(null, this.state.selectedIndex) 97 | }) 98 | } 99 | }) 100 | } 101 | 102 | showContextMenu(e, row) { 103 | this.handleSelect(null, row) 104 | 105 | const menu = remote.Menu.buildFromTemplate([ 106 | { 107 | label: 'Copy to Clipboard', 108 | click: () => { 109 | clipboard.writeText(this.state.members[row][0]) 110 | } 111 | }, 112 | { 113 | type: 'separator' 114 | }, 115 | { 116 | label: 'Rename Key', 117 | click: () => { 118 | this.setState({editableIndex: row}) 119 | } 120 | }, 121 | { 122 | label: 'Delete', 123 | click: () => { 124 | this.deleteSelectedMember() 125 | } 126 | } 127 | ]) 128 | menu.popup(remote.getCurrentWindow()) 129 | } 130 | 131 | render() { 132 | return ( 139 |
146 | { 153 | this.handleSelect(evt, index) 154 | this.setState({editableIndex: index}) 155 | }} 156 | width={this.props.contentBarWidth} 157 | height={this.props.height} 158 | headerHeight={24} 159 | > 160 | { 164 | showModal({ 165 | button: 'Insert Member', 166 | form: { 167 | type: 'object', 168 | properties: { 169 | 'Key:': { 170 | type: 'string' 171 | } 172 | } 173 | } 174 | }).then(res => { 175 | const data = res['Key:'] 176 | const value = 'New Member' 177 | this.props.redis.hsetnx(this.state.keyName, data, value).then(inserted => { 178 | if (!inserted) { 179 | alert('The field already exists') 180 | return 181 | } 182 | this.state.members.push([data, Buffer.from(value)]) 183 | this.setState({ 184 | members: this.state.members, 185 | length: this.state.length + 1 186 | }, () => { 187 | this.props.onKeyContentChange() 188 | this.handleSelect(null, this.state.members.length - 1) 189 | }) 190 | }) 191 | }) 192 | }} 193 | /> 194 | } 195 | width={this.props.contentBarWidth} 196 | cell={({rowIndex}) => { 197 | const member = this.state.members[rowIndex] 198 | if (!member) { 199 | this.load(rowIndex) 200 | return 'Loading...' 201 | } 202 | return ( { 206 | const members = this.state.members 207 | const member = members[rowIndex] 208 | const keyName = this.state.keyName 209 | const source = member[0] 210 | if (source !== target && target) { 211 | this.props.redis.hexists(keyName, target).then(exists => { 212 | if (exists) { 213 | return showModal({ 214 | title: 'Overwrite the field?', 215 | button: 'Overwrite', 216 | content: `Field "${target}" already exists. Are you sure you want to overwrite this field?` 217 | }).then(() => { 218 | let found 219 | for (let i = 0; i < members.length; i++) { 220 | if (members[i][0] === target) { 221 | found = i 222 | break 223 | } 224 | } 225 | if (typeof found === 'number') { 226 | members.splice(found, 1) 227 | this.setState({length: this.state.length - 1}) 228 | } 229 | }) 230 | } 231 | }).then(() => { 232 | member[0] = target 233 | this.props.redis.multi() 234 | .hdel(keyName, source) 235 | .hset(keyName, target, member[1]).exec() 236 | this.setState({members}) 237 | }).catch(() => {}) 238 | } 239 | this.setState({editableIndex: null}) 240 | ReactDOM.findDOMNode(this).focus() 241 | }} 242 | html={member[0]} 243 | />) 244 | }} 245 | /> 246 |
247 |
248 | 252 |
) 253 | } 254 | } 255 | 256 | export default HashContent 257 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/ListContent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import BaseContent from '.' 5 | import SplitPane from 'react-split-pane' 6 | import {Table, Column} from 'fixed-data-table-contextmenu' 7 | import Editor from './Editor' 8 | import SortHeaderCell from './SortHeaderCell' 9 | import AddButton from '../../../AddButton' 10 | import {remote} from 'electron' 11 | 12 | class ListContent extends BaseContent { 13 | save(value, callback) { 14 | const {selectedIndex, keyName, desc} = this.state 15 | if (typeof selectedIndex === 'number') { 16 | const members = this.state.members.slice() 17 | members[selectedIndex] = value.toString() 18 | this.setState({members}) 19 | this.props.redis.lset(keyName, desc ? -1 - selectedIndex : selectedIndex, value, (err, res) => { 20 | this.props.onKeyContentChange() 21 | callback(err, res) 22 | }) 23 | } else { 24 | alert('Please wait for data been loaded before saving.') 25 | } 26 | } 27 | 28 | load(index) { 29 | if (!super.load(index)) { 30 | return 31 | } 32 | 33 | const {members, length, keyName, desc} = this.state 34 | let from = members.length 35 | let to = Math.min(from === 0 ? 200 : from + 1000, length - 1) 36 | if (to < from) { 37 | this.loading = false 38 | return 39 | } 40 | if (desc) { 41 | [from, to] = [-1 - to, -1 - from] 42 | } 43 | 44 | this.props.redis.lrange(keyName, from, to, (_, results) => { 45 | if (this.state.desc !== desc) { 46 | // TODO: use a counter instead to avoid 47 | // cancel multiple loading attempts. 48 | // LIST & ZSET 49 | this.loading = false 50 | return 51 | } 52 | if (desc) { 53 | results.reverse() 54 | } 55 | const diff = to - from + 1 - results.length 56 | this.setState({ 57 | members: members.concat(results), 58 | length: length - diff 59 | }, () => { 60 | if (typeof this.state.selectedIndex !== 'number' && this.state.members.length) { 61 | this.handleSelect(null, 0) 62 | } 63 | this.loading = false 64 | if (this.state.members.length - 1 < this.maxRow && !diff) { 65 | this.load() 66 | } 67 | }) 68 | }) 69 | } 70 | 71 | handleSelect(_, selectedIndex) { 72 | if (typeof this.state.members[selectedIndex] === 'undefined') { 73 | this.setState({selectedIndex: null}) 74 | } else { 75 | this.setState({selectedIndex}) 76 | } 77 | } 78 | 79 | async deleteSelectedMember() { 80 | if (typeof this.state.selectedIndex !== 'number') { 81 | return 82 | } 83 | await showModal({ 84 | title: 'Delete selected item?', 85 | button: 'Delete', 86 | content: 'Are you sure you want to delete the selected item? This action cannot be undone.' 87 | }) 88 | const {selectedIndex, desc, length, keyName} = this.state 89 | const members = this.state.members.slice() 90 | const deleted = members.splice(selectedIndex, 1) 91 | if (deleted.length) { 92 | this.props.redis.lremindex(keyName, desc ? -1 - selectedIndex : selectedIndex) 93 | 94 | const nextSelectedIndex = selectedIndex >= members.length - 1 95 | ? selectedIndex - 1 96 | : selectedIndex 97 | this.setState({members, length: length - 1}, () => { 98 | this.props.onKeyContentChange() 99 | this.handleSelect(null, nextSelectedIndex) 100 | }) 101 | } 102 | } 103 | 104 | handleKeyDown(e) { 105 | if (typeof this.state.selectedIndex === 'number') { 106 | switch (e.keyCode) { 107 | case 8: 108 | this.deleteSelectedMember() 109 | return false 110 | case 38: 111 | if (this.state.selectedIndex > 0) { 112 | this.handleSelect(null, this.state.selectedIndex - 1) 113 | } 114 | return false 115 | case 40: 116 | if (this.state.selectedIndex < this.state.members.length - 1) { 117 | this.handleSelect(null, this.state.selectedIndex + 1) 118 | } 119 | return false 120 | } 121 | } 122 | } 123 | 124 | showContextMenu(e, row) { 125 | this.handleSelect(null, row) 126 | 127 | const menu = remote.Menu.buildFromTemplate([ 128 | { 129 | label: 'Delete', 130 | click: () => { 131 | this.deleteSelectedMember() 132 | } 133 | } 134 | ]) 135 | menu.popup(remote.getCurrentWindow()) 136 | } 137 | 138 | renderEditor() { 139 | const content = this.state.members[this.state.selectedIndex] 140 | const buffer = typeof content === 'string' 141 | ? Buffer.from(content) 142 | : undefined 143 | return 147 | } 148 | 149 | renderIndexColumn() { 150 | return this.setState({ 155 | desc, 156 | members: [], 157 | selectedIndex: null 158 | })} 159 | desc={this.state.desc} 160 | /> 161 | } 162 | width={this.props.indexBarWidth} 163 | isResizable 164 | cell={({rowIndex}) => { 165 | return
{ this.state.desc ? this.state.length - 1 - rowIndex : rowIndex }
166 | }} 167 | /> 168 | } 169 | 170 | renderValueColumn() { 171 | return { 175 | const res = await showModal({ 176 | button: 'Insert Item', 177 | form: { 178 | type: 'object', 179 | properties: { 180 | 'Insert To:': { 181 | type: 'string', 182 | enum: ['head', 'tail'] 183 | } 184 | } 185 | } 186 | }) 187 | const insertToHead = res['Insert To:'] === 'head' 188 | const method = insertToHead ? 'lpush' : 'rpush' 189 | const data = 'New Item' 190 | await this.props.redis[method](this.state.keyName, data) 191 | 192 | const members = this.state.members.slice() 193 | members[insertToHead ? 'unshift' : 'push'](data) 194 | this.setState({ 195 | members, 196 | length: this.state.length + 1 197 | }, () => { 198 | this.props.onKeyContentChange() 199 | if (insertToHead) { 200 | this.handleSelect(null, 0) 201 | } 202 | }) 203 | }} 204 | /> 205 | } 206 | width={this.props.contentBarWidth - this.props.indexBarWidth} 207 | cell={({rowIndex}) => { 208 | const data = this.state.members[rowIndex] 209 | if (typeof data === 'undefined') { 210 | this.load(rowIndex) 211 | return 'Loading...' 212 | } 213 | return
{data}
214 | }} 215 | /> 216 | } 217 | 218 | render() { 219 | return ( 226 |
232 | 244 | {this.renderIndexColumn()} 245 | {this.renderValueColumn()} 246 |
247 |
248 | {this.renderEditor()} 249 |
) 250 | } 251 | } 252 | 253 | export default ListContent 254 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/SetContent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import BaseContent from '.' 5 | import SplitPane from 'react-split-pane' 6 | import {Table, Column} from 'fixed-data-table-contextmenu' 7 | import Editor from './Editor' 8 | import AddButton from '../../../AddButton' 9 | import {remote} from 'electron' 10 | 11 | require('./index.scss') 12 | 13 | class SetContent extends BaseContent { 14 | save(value, callback) { 15 | if (typeof this.state.selectedIndex === 'number') { 16 | const oldValue = this.state.members[this.state.selectedIndex] 17 | 18 | const key = this.state.keyName 19 | this.props.redis.sismember(key, value).then(exists => { 20 | if (exists) { 21 | callback(new Error('The value already exists in the set')) 22 | return 23 | } 24 | this.props.redis.multi().srem(key, oldValue).sadd(key, value).exec((err, res) => { 25 | if (!err) { 26 | this.state.members[this.state.selectedIndex] = value.toString() 27 | this.setState({members: this.state.members}) 28 | } 29 | this.props.onKeyContentChange() 30 | callback(err, res) 31 | }) 32 | }) 33 | } else { 34 | alert('Please wait for data been loaded before saving.') 35 | } 36 | } 37 | 38 | load(index) { 39 | if (!super.load(index)) { 40 | return 41 | } 42 | const count = Number(this.cursor) ? 10000 : 500 43 | this.props.redis.sscan(this.state.keyName, this.cursor, 'COUNT', count, (_, [cursor, results]) => { 44 | this.cursor = cursor 45 | const length = Number(cursor) ? this.state.length : this.state.members.length + results.length 46 | 47 | this.setState({ 48 | members: this.state.members.concat(results), 49 | length 50 | }, () => { 51 | if (typeof this.state.selectedIndex !== 'number' && this.state.members.length) { 52 | this.handleSelect(null, 0) 53 | } 54 | this.loading = false 55 | if (this.state.members.length - 1 < this.maxRow && Number(cursor)) { 56 | this.load() 57 | } 58 | }) 59 | }) 60 | } 61 | 62 | handleSelect(evt, selectedIndex) { 63 | const content = this.state.members[selectedIndex] 64 | if (typeof content !== 'undefined') { 65 | this.setState({selectedIndex, content}) 66 | } 67 | } 68 | 69 | handleKeyDown(e) { 70 | if (typeof this.state.selectedIndex === 'number') { 71 | if (e.keyCode === 8) { 72 | this.deleteSelectedMember() 73 | return false 74 | } 75 | if (e.keyCode === 38) { 76 | if (this.state.selectedIndex > 0) { 77 | this.handleSelect(null, this.state.selectedIndex - 1) 78 | } 79 | return false 80 | } 81 | if (e.keyCode === 40) { 82 | if (this.state.selectedIndex < this.state.members.length - 1) { 83 | this.handleSelect(null, this.state.selectedIndex + 1) 84 | } 85 | return false 86 | } 87 | } 88 | } 89 | 90 | deleteSelectedMember() { 91 | if (typeof this.state.selectedIndex !== 'number') { 92 | return 93 | } 94 | showModal({ 95 | title: 'Delete selected item?', 96 | button: 'Delete', 97 | content: 'Are you sure you want to delete the selected item? This action cannot be undone.' 98 | }).then(() => { 99 | const members = this.state.members 100 | const deleted = members.splice(this.state.selectedIndex, 1) 101 | if (deleted.length) { 102 | this.props.redis.srem(this.state.keyName, deleted) 103 | if (this.state.selectedIndex >= members.length - 1) { 104 | this.state.selectedIndex -= 1 105 | } 106 | this.setState({members, length: this.state.length - 1}, () => { 107 | this.props.onKeyContentChange() 108 | this.handleSelect(null, this.state.selectedIndex) 109 | }) 110 | } 111 | }) 112 | } 113 | 114 | showContextMenu(e, row) { 115 | this.handleSelect(null, row) 116 | 117 | const menu = remote.Menu.buildFromTemplate([ 118 | { 119 | label: 'Delete', 120 | click: () => { 121 | this.deleteSelectedMember() 122 | } 123 | } 124 | ]) 125 | menu.popup(remote.getCurrentWindow()) 126 | } 127 | 128 | render() { 129 | return ( 137 |
143 | 153 | { 156 | const member = this.state.members[rowIndex] 157 | if (typeof member === 'undefined') { 158 | this.load(rowIndex) 159 | return 'Loading...' 160 | } 161 | return
{member}
162 | }} 163 | header={ 164 | { 166 | showModal({ 167 | button: 'Insert Member', 168 | form: { 169 | type: 'object', 170 | properties: { 171 | 'Value:': { 172 | type: 'string' 173 | } 174 | } 175 | } 176 | }).then(res => { 177 | const data = res['Value:'] 178 | return this.props.redis.sismember(this.state.keyName, data).then(exists => { 179 | if (exists) { 180 | const error = 'Member already exists' 181 | alert(error) 182 | throw new Error(error) 183 | } 184 | return data 185 | }) 186 | }).then(data => { 187 | this.props.redis.sadd(this.state.keyName, data).then(() => { 188 | this.state.members.push(data) 189 | this.setState({ 190 | members: this.state.members, 191 | length: this.state.length + 1 192 | }, () => { 193 | this.props.onKeyContentChange() 194 | this.handleSelect(null, this.state.members.length - 1) 195 | }) 196 | }) 197 | }) 198 | }} 199 | /> 200 | } 201 | /> 202 |
203 |
204 | 208 |
) 209 | } 210 | } 211 | 212 | export default SetContent 213 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/SortHeaderCell.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {memo} from 'react' 4 | import {Cell} from 'fixed-data-table-contextmenu' 5 | 6 | function SortHeaderCell({onOrderChange, desc, title}) { 7 | function handleOnClick(evt) { 8 | onOrderChange(!desc) 9 | evt.preventDefault() 10 | evt.stopPropagation() 11 | } 12 | 13 | return ( 16 | 17 | {title} 18 | { 19 | 24 | } 25 | 26 | ) 27 | } 28 | 29 | export default memo(SortHeaderCell) 30 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/StringContent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import BaseContent from '.' 5 | import Editor from './Editor' 6 | 7 | class StringContent extends BaseContent { 8 | init(keyName, keyType) { 9 | super.init(keyName, keyType) 10 | this.props.redis.getBuffer(keyName, (_, buffer) => { 11 | this.setState({buffer: buffer instanceof Buffer ? buffer : Buffer.alloc(0)}) 12 | }) 13 | } 14 | 15 | save(value, callback) { 16 | if (this.state.keyName) { 17 | this.props.redis.setKeepTTL(this.state.keyName, value, (err, res) => { 18 | this.props.onKeyContentChange() 19 | callback(err, res) 20 | }) 21 | } else { 22 | alert('Please wait for data been loaded before saving.') 23 | } 24 | } 25 | 26 | create() { 27 | return this.props.redis.set(this.state.keyName, '') 28 | } 29 | 30 | render() { 31 | return () 35 | } 36 | } 37 | 38 | export default StringContent 39 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/ZSetContent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import BaseContent from '.' 5 | import SplitPane from 'react-split-pane' 6 | import {Table, Column} from 'fixed-data-table-contextmenu' 7 | import Editor from './Editor' 8 | import SortHeaderCell from './SortHeaderCell' 9 | import AddButton from '../../../AddButton' 10 | import ContentEditable from '../../../ContentEditable' 11 | import ReactDOM from 'react-dom' 12 | import {clipboard, remote} from 'electron' 13 | import sortedIndexBy from 'lodash.sortedindexby' 14 | 15 | require('./index.scss') 16 | 17 | class ZSetContent extends BaseContent { 18 | save(value, callback) { 19 | const {selectedIndex, members, keyName} = this.state 20 | if (typeof selectedIndex === 'number') { 21 | const item = members[selectedIndex] 22 | const oldValue = item[0] 23 | item[0] = value.toString() 24 | this.setState({members}) 25 | this.props.redis.multi() 26 | .zrem(keyName, oldValue) 27 | .zadd(keyName, item[1], value) 28 | .exec((err, res) => { 29 | this.props.onKeyContentChange() 30 | callback(err, res) 31 | }) 32 | } else { 33 | alert('Please wait for data been loaded before saving.') 34 | } 35 | } 36 | 37 | load(index) { 38 | if (!super.load(index)) { 39 | return 40 | } 41 | const {members, desc, length, keyName} = this.state 42 | const from = members.length 43 | const to = Math.min(from === 0 ? 200 : from + 1000, length - 1) 44 | 45 | const commandName = desc ? 'zrevrange' : 'zrange' 46 | this.props.redis[commandName](keyName, from, to, 'WITHSCORES', (_, results) => { 47 | if (this.state.desc !== desc || this.state.members.length !== from) { 48 | this.loading = false 49 | return 50 | } 51 | const items = [] 52 | for (let i = 0; i < results.length - 1; i += 2) { 53 | items.push([results[i], results[i + 1]]) 54 | } 55 | const diff = to - from + 1 - items.length 56 | this.setState({ 57 | members: members.concat(items), 58 | length: length - diff 59 | }, () => { 60 | const currentMembers = this.state.members 61 | if (typeof this.state.selectedIndex !== 'number' && currentMembers.length) { 62 | this.handleSelect(null, 0) 63 | } 64 | this.loading = false 65 | if (currentMembers.length - 1 < this.maxRow && !diff) { 66 | this.load() 67 | } 68 | }) 69 | }) 70 | } 71 | 72 | handleSelect(_, selectedIndex) { 73 | const item = this.state.members[selectedIndex] 74 | if (item) { 75 | this.setState({selectedIndex}) 76 | } 77 | } 78 | 79 | handleKeyDown(e) { 80 | const {selectedIndex, editableIndex, members} = this.state 81 | if (typeof selectedIndex === 'number' && typeof editableIndex !== 'number') { 82 | switch (e.keyCode) { 83 | case 8: 84 | this.deleteSelectedMember() 85 | return false 86 | case 38: 87 | if (selectedIndex > 0) { 88 | this.handleSelect(null, selectedIndex - 1) 89 | } 90 | return false 91 | case 40: 92 | if (selectedIndex < members.length - 1) { 93 | this.handleSelect(null, selectedIndex + 1) 94 | } 95 | return false 96 | } 97 | } 98 | } 99 | 100 | deleteSelectedMember() { 101 | if (typeof this.state.selectedIndex !== 'number') { 102 | return 103 | } 104 | showModal({ 105 | title: 'Delete selected item?', 106 | button: 'Delete', 107 | content: 'Are you sure you want to delete the selected item? This action cannot be undone.' 108 | }).then(() => { 109 | const members = this.state.members 110 | const deleted = members.splice(this.state.selectedIndex, 1) 111 | if (deleted.length) { 112 | this.props.redis.zrem(this.state.keyName, deleted[0]) 113 | const nextSelectedIndex = this.state.selectedIndex >= members.length - 1 114 | ? this.state.selectedIndex - 1 115 | : this.state.selectedIndex 116 | this.setState({members, length: this.state.length - 1}, () => { 117 | this.props.onKeyContentChange() 118 | this.handleSelect(null, nextSelectedIndex) 119 | }) 120 | } 121 | }) 122 | } 123 | 124 | showContextMenu(_, row) { 125 | this.handleSelect(null, row) 126 | 127 | const menu = remote.Menu.buildFromTemplate([ 128 | { 129 | label: 'Copy Score to Clipboard', 130 | click: () => { 131 | clipboard.writeText(this.state.members[row][1]) 132 | } 133 | }, 134 | { 135 | type: 'separator' 136 | }, 137 | { 138 | label: 'Edit Score', 139 | click: () => { 140 | this.setState({editableIndex: row}) 141 | } 142 | }, 143 | { 144 | label: 'Delete', 145 | click: () => { 146 | this.deleteSelectedMember() 147 | } 148 | } 149 | ]) 150 | menu.popup(remote.getCurrentWindow()) 151 | } 152 | 153 | renderTable() { 154 | return { 161 | this.handleSelect(evt, index) 162 | this.setState({editableIndex: index}) 163 | }} 164 | isColumnResizing={false} 165 | onColumnResizeEndCallback={this.props.setSize.bind(null, 'score')} 166 | width={this.props.contentBarWidth} 167 | height={this.props.height} 168 | headerHeight={24} 169 | > 170 | {this.renderScoreColumn()} 171 | {this.renderMemberColumn()} 172 |
173 | } 174 | 175 | renderScoreColumn() { 176 | return this.setState({ 181 | desc, 182 | members: [], 183 | selectedIndex: null 184 | })} 185 | desc={this.state.desc} 186 | /> 187 | } 188 | width={this.props.scoreBarWidth} 189 | isResizable 190 | cell={({rowIndex}) => { 191 | const member = this.state.members[rowIndex] 192 | if (!member) { 193 | return '' 194 | } 195 | return ( { 199 | const members = this.state.members.slice() 200 | 201 | try { 202 | await this.props.redis.zadd(this.state.keyName, newScore, members[rowIndex][0]) 203 | // Don't sort when changing scores 204 | members[rowIndex][1] = newScore 205 | this.setState({ 206 | members, 207 | editableIndex: null 208 | }) 209 | } catch (err) { 210 | alert(err.message) 211 | this.setState({ 212 | editableIndex: null 213 | }) 214 | } 215 | ReactDOM.findDOMNode(this.refs.table).focus() 216 | }} 217 | html={member[1]} 218 | />) 219 | }} 220 | /> 221 | } 222 | 223 | renderMemberColumn() { 224 | return { 228 | const res = await showModal({ 229 | button: 'Insert Member', 230 | form: { 231 | type: 'object', 232 | properties: { 233 | 'Value:': { 234 | type: 'string' 235 | }, 236 | 'Score:': { 237 | type: 'number' 238 | } 239 | } 240 | } 241 | }) 242 | const data = res['Value:'] 243 | const score = res['Score:'] 244 | const rank = await this.props.redis.zscore(this.state.keyName, data) 245 | if (rank !== null) { 246 | const error = 'Member already exists' 247 | alert(error) 248 | return 249 | } 250 | await this.props.redis.zadd(this.state.keyName, score, data) 251 | const members = this.state.members.slice() 252 | const newMember = [data, score] 253 | const index = sortedIndexBy( 254 | members, 255 | newMember, 256 | (member) => Number(member[1]) * (this.state.desc ? -1 : 1) 257 | ) 258 | if (index < members.length - 1) { 259 | members.splice(index, 0, newMember) 260 | this.setState({ 261 | members, 262 | length: this.state.length + 1 263 | }, () => { 264 | this.props.onKeyContentChange() 265 | this.handleSelect(null, index) 266 | }) 267 | } 268 | alert('Added successfully') 269 | }}/> 270 | } 271 | width={this.props.contentBarWidth - this.props.scoreBarWidth} 272 | cell={({rowIndex}) => { 273 | const member = this.state.members[rowIndex] 274 | if (!member) { 275 | this.load(rowIndex) 276 | return 'Loading...' 277 | } 278 | return
{member[0]}
279 | }} 280 | /> 281 | } 282 | 283 | renderEditor() { 284 | const item = this.state.members[this.state.selectedIndex] 285 | const buffer = item 286 | ? Buffer.from(item[0]) 287 | : undefined 288 | return 292 | } 293 | 294 | render() { 295 | return ( 302 |
308 | {this.renderTable()} 309 |
310 | {this.renderEditor()} 311 |
) 312 | } 313 | } 314 | 315 | export default ZSetContent 316 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | 5 | require('./index.scss') 6 | 7 | const getDefaultState = function () { 8 | return { 9 | keyName: null, 10 | content: null, 11 | desc: false, 12 | length: 0, 13 | members: [] 14 | } 15 | } 16 | 17 | class BaseContent extends React.Component { 18 | constructor() { 19 | super() 20 | this.state = getDefaultState() 21 | this.maxRow = 0 22 | this.cursor = 0 23 | this.randomClass = 'base-content-' + (Math.random() * 100000 | 0) 24 | } 25 | 26 | init(keyName, keyType) { 27 | if (!keyName || !keyType) { 28 | return 29 | } 30 | this.loading = false 31 | this.setState(getDefaultState()) 32 | 33 | const {redis} = this.props 34 | 35 | const method = { 36 | string: 'strlen', 37 | list: 'llen', 38 | set: 'scard', 39 | zset: 'zcard', 40 | hash: 'hlen' 41 | }[keyType] 42 | 43 | redis[method](keyName).then(length => { 44 | this.setState({keyName, length: length || 0}) 45 | }) 46 | } 47 | 48 | load(index) { 49 | if (index > this.maxRow) { 50 | this.maxRow = index 51 | } 52 | if (this.loading) { 53 | return 54 | } 55 | this.loading = true 56 | return true 57 | } 58 | 59 | rowClassGetter(index) { 60 | const item = this.state.members[index] 61 | if (typeof item === 'undefined') { 62 | return 'type-list is-loading' 63 | } 64 | if (index === this.state.selectedIndex) { 65 | return 'type-list is-selected' 66 | } 67 | return 'type-list' 68 | } 69 | 70 | componentDidMount() { 71 | this.init(this.props.keyName, this.props.keyType) 72 | } 73 | 74 | componentDidUpdate() { 75 | if (typeof this.state.scrollToRow === 'number') { 76 | this.setState({scrollToRow: null}) 77 | } 78 | } 79 | 80 | componentWillReceiveProps(nextProps) { 81 | if (nextProps.keyName !== this.props.keyName || 82 | nextProps.keyType !== this.props.keyType) { 83 | this.init(nextProps.keyName, nextProps.keyType) 84 | } 85 | } 86 | 87 | componentWillUnmount() { 88 | this.setState = function () {} 89 | } 90 | } 91 | 92 | export default BaseContent 93 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/BaseContent/index.scss: -------------------------------------------------------------------------------- 1 | .BaseContent { 2 | flex: 1; 3 | position: relative; 4 | 5 | .type-list { 6 | .index-label { 7 | background: #ccc; 8 | margin: 4px 4px 0 0; 9 | font-family: Consolas, monospace !important; 10 | padding: 0 4px !important; 11 | height: 16px; 12 | font-size: 11px !important; 13 | line-height: 16px !important; 14 | display: block; 15 | text-align: center; 16 | color: #fff; 17 | white-space: nowrap; 18 | overflow: hidden; 19 | text-overflow: ellipsis; 20 | } 21 | } 22 | 23 | .SortHeaderCell { 24 | position: relative; 25 | 26 | a { 27 | display: block; 28 | } 29 | 30 | img { 31 | position: absolute; 32 | right: -15px; 33 | top: 5px; 34 | } 35 | 36 | &.is-asc img { 37 | transform: rotate(180deg); 38 | } 39 | } 40 | .base-content { 41 | margin-top: -1px; 42 | position: relative; 43 | overflow: hidden; 44 | &:focus { 45 | outline: 0; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {PureComponent} from 'react' 4 | import {connect} from 'react-redux' 5 | import {setSize} from 'Redux/actions' 6 | import StringContent from './BaseContent/StringContent' 7 | import ListContent from './BaseContent/ListContent' 8 | import SetContent from './BaseContent/SetContent' 9 | import HashContent from './BaseContent/HashContent' 10 | import ZSetContent from './BaseContent/ZSetContent' 11 | 12 | require('./index.scss') 13 | 14 | class KeyContent extends PureComponent { 15 | constructor() { 16 | super() 17 | this.state = {} 18 | } 19 | 20 | render() { 21 | const props = {key: this.props.keyName, ...this.props} 22 | let view 23 | switch (this.props.keyType) { 24 | case 'string': view = ; break 25 | case 'list': view = ; break 26 | case 'set': view = ; break 27 | case 'hash': view = ; break 28 | case 'zset': view = ; break 29 | case 'none': 30 | view = (
31 | 32 |

The key has been deleted

33 |
) 34 | break 35 | } 36 | return
{ view }
37 | } 38 | } 39 | 40 | function mapStateToProps(state) { 41 | return { 42 | contentBarWidth: state.sizes.get('contentBarWidth') || 200, 43 | scoreBarWidth: state.sizes.get('scoreBarWidth') || 60, 44 | indexBarWidth: state.sizes.get('indexBarWidth') || 60 45 | } 46 | } 47 | 48 | const mapDispatchToProps = { 49 | setSize 50 | } 51 | 52 | export default connect(mapStateToProps, mapDispatchToProps)(KeyContent) 53 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/KeyContent/index.scss: -------------------------------------------------------------------------------- 1 | .notfound { 2 | position: absolute; 3 | top: 50%; 4 | font-size: 22px; 5 | color: #ccc; 6 | text-align: center; 7 | transform: translateY(-50%); 8 | width: 100%; 9 | 10 | p { 11 | margin: 0; 12 | } 13 | 14 | .icon { 15 | font-size: 62px; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/TabBar/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {memo} from 'react' 4 | require('./index.scss') 5 | 6 | const TABS = ['Content', 'Terminal', 'Config'] 7 | 8 | function renderTabIcon(tab) { 9 | switch (tab) { 10 | case 'Content': 11 | return 12 | case 'Terminal': 13 | return 14 | case 'Config': 15 | return 16 | } 17 | } 18 | 19 | function renderTab(tab, {activeTab, onSelectTab}) { 20 | return
onSelectTab(tab)} 24 | > 25 | {renderTabIcon(tab)} 26 | {tab} 27 |
28 | } 29 | 30 | function Content(props) { 31 | return
{TABS.map(tab => renderTab(tab, props))}
32 | } 33 | 34 | export default memo(Content) 35 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/TabBar/index.scss: -------------------------------------------------------------------------------- 1 | .TabBar { 2 | text-align: right; 3 | border-bottom: 1px solid #d3d3d3; 4 | .item { 5 | display: inline-block; 6 | padding: 12px; 7 | 8 | .icon { 9 | margin-right: 5px; 10 | } 11 | 12 | &.is-active { 13 | background: #dbdfe1; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/Terminal/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import commands from 'redis-commands' 5 | import splitargs from 'redis-splitargs' 6 | import 'jquery.terminal' 7 | 8 | require('../../../../../../../../node_modules/jquery.terminal/css/jquery.terminal.css') 9 | require('./index.scss') 10 | 11 | class Terminal extends React.PureComponent { 12 | constructor() { 13 | super() 14 | this.onSelectBinded = this.onSelect.bind(this) 15 | } 16 | 17 | componentDidMount() { 18 | const {redis} = this.props 19 | redis.on('select', this.onSelectBinded) 20 | const terminal = this.terminal = $(this.refs.terminal).terminal((command, term) => { 21 | if (!command) { 22 | return 23 | } 24 | command = splitargs(command) 25 | const commandName = command[0] && command[0].toUpperCase() 26 | if (commandName === 'FLUSHALL' || commandName === 'FLUSHDB') { 27 | term.push(input => { 28 | if (input.match(/y|yes/i)) { 29 | this.execute(term, command) 30 | term.pop() 31 | } else if (input.match(/n|no/i)) { 32 | term.pop() 33 | } 34 | }, { 35 | prompt: '[[;#aac6e3;]Are you sure (y/n)? ]' 36 | }) 37 | } else { 38 | this.execute(term, command) 39 | } 40 | }, { 41 | greetings: '', 42 | exit: false, 43 | completion(command, callback) { 44 | const commandName = command.split(' ')[0] 45 | const lower = commandName.toLowerCase() 46 | const isUppercase = commandName.toUpperCase() === commandName 47 | callback( 48 | commands.list 49 | .filter(item => item.indexOf(lower) === 0) 50 | .map(item => { 51 | const last = item.slice(commandName.length) 52 | return commandName + (isUppercase ? last.toUpperCase() : last) 53 | }) 54 | ) 55 | }, 56 | name: this.props.connectionKey, 57 | height: '100%', 58 | width: '100%', 59 | outputLimit: 200, 60 | prompt: `[[;#fff;]redis> ]`, 61 | keydown(e) { 62 | if (!terminal.enabled()) { 63 | return true 64 | } 65 | if (e.ctrlKey || e.metaKey) { 66 | if (e.keyCode >= 48 && e.keyCode <= 57) { 67 | return true 68 | } 69 | if ([84, 87, 78, 82, 81].indexOf(e.keyCode) !== -1) { 70 | return true 71 | } 72 | } 73 | if (e.ctrlKey) { 74 | if (e.keyCode === 67) { 75 | if (terminal.level() > 1) { 76 | terminal.pop() 77 | if (terminal.paused()) { 78 | terminal.resume() 79 | } 80 | } 81 | return false 82 | } 83 | } 84 | } 85 | }) 86 | } 87 | 88 | onSelect(db) { 89 | this.props.onDatabaseChange(db) 90 | } 91 | 92 | execute(term, args) { 93 | term.pause() 94 | const redis = this.props.redis 95 | if (args.length === 1 && args[0].toUpperCase() === 'MONITOR') { 96 | redis.monitor((_, monitor) => { 97 | term.echo('[[;#aac6e3;]Enter monitor mode. Press Ctrl+C to exit. ]') 98 | term.resume() 99 | term.push(input => { 100 | }, { 101 | onExit() { 102 | monitor.disconnect() 103 | } 104 | }) 105 | monitor.on('monitor', (time, args) => { 106 | if (term.level() > 1) { 107 | term.echo(formatMonitor(time, args), {raw: true}) 108 | } 109 | }) 110 | }) 111 | } else if (args.length > 1 && ['SUBSCRIBE', 'PSUBSCRIBE'].indexOf(args[0].toUpperCase()) !== -1) { 112 | const newRedis = redis.duplicate() 113 | newRedis.call.apply(newRedis, args).then(res => { 114 | term.echo('[[;#aac6e3;]Enter subscription mode. Press Ctrl+C to exit. ]') 115 | term.resume() 116 | term.push(input => { 117 | }, { 118 | prompt: '', 119 | onExit() { 120 | newRedis.disconnect() 121 | } 122 | }) 123 | }) 124 | newRedis.on('message', (channel, message) => { 125 | term.echo(formatMessage(channel, message), {raw: true}) 126 | }) 127 | newRedis.on('pmessage', (pattern, channel, message) => { 128 | term.echo(formatMessage(channel, message), {raw: true}) 129 | }) 130 | } else { 131 | redis.call.apply(redis, args).then(res => { 132 | term.echo(getHTML(res), {raw: true}) 133 | term.resume() 134 | }).catch(err => { 135 | term.echo(getHTML(err), {raw: true}) 136 | term.resume() 137 | }) 138 | } 139 | } 140 | 141 | componentWillReceiveProps(nextProps) { 142 | if (this.props.style.display === 'none' && nextProps.style.display === 'block') { 143 | this.terminal.focus() 144 | } 145 | } 146 | 147 | componentWillUnmount() { 148 | this.props.redis.removeAllListeners('select', this.onSelectBinded) 149 | } 150 | 151 | render() { 152 | return (
) 153 | } 154 | } 155 | 156 | export default Terminal 157 | 158 | function getHTML(response) { 159 | if (Array.isArray(response)) { 160 | return `
    161 | ${response.map((item, index) => '
  • ' + index + '' + getHTML(item) + '
  • ').join('')} 162 |
` 163 | } 164 | const type = typeof response 165 | if (type === 'number') { 166 | return `
${response}
` 167 | } 168 | if (type === 'string') { 169 | return `
${response.replace(/\r?\n/g, '
')}
` 170 | } 171 | if (response === null) { 172 | return `
null
` 173 | } 174 | if (response instanceof Error) { 175 | return `
${response.message}
` 176 | } 177 | if (type === 'object') { 178 | return `
    179 | ${Object.keys(response).map(item => '
  • ' + item + '' + getHTML(response[item]) + '
  • ').join('')} 180 |
      ` 181 | } 182 | 183 | return `
      ${JSON.stringify(response)}
      ` 184 | } 185 | 186 | function formatMonitor(time, args) { 187 | args = args || [] 188 | const command = args[0] ? args.shift().toUpperCase() : '' 189 | if (command) { 190 | commands.getKeyIndexes(command.toLowerCase(), args).forEach(index => { 191 | args[index] = `${args[index]}` 192 | }) 193 | } 194 | return `
      195 | ${time} 196 | 197 | ${command} 198 | ${args.join(' ')} 199 | 200 |
      ` 201 | } 202 | 203 | function formatMessage(channel, message) { 204 | return `
      205 | ${channel} 206 | ${message} 207 |
      ` 208 | } 209 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/Terminal/index.scss: -------------------------------------------------------------------------------- 1 | .Terminal { 2 | overflow: auto; 3 | flex: 1; 4 | * { 5 | -webkit-user-select: text; 6 | } 7 | 8 | &.terminal, .cmd { 9 | --background: #272b34; 10 | --background: #000; 11 | --size: 1.4; 12 | font-family: Consolas, monospace; 13 | } 14 | 15 | .number { 16 | color: #78CF8A; 17 | } 18 | 19 | .string { 20 | color: #d6ec9c; 21 | } 22 | 23 | .array-resp, .object-resp { 24 | margin: 0; 25 | padding: 0; 26 | 27 | li { 28 | display: flex; 29 | span { 30 | color: #848080; 31 | min-width: 28px; 32 | text-align: right; 33 | margin-right: 10px; 34 | } 35 | div { 36 | flex: 1; 37 | } 38 | } 39 | } 40 | 41 | .object-resp li span { 42 | font-weight: bold; 43 | color: #cda869; 44 | } 45 | 46 | .null { 47 | color: #cf7ea9; 48 | } 49 | 50 | .error { 51 | color: #ee6868 !important; 52 | } 53 | 54 | .list { 55 | color: #8f9d6a; 56 | } 57 | 58 | .monitor { 59 | color: #8f9d6a; 60 | .time { 61 | color: #3d3d3d; 62 | margin-right: 4px; 63 | } 64 | 65 | .command-name { 66 | color: #cf7ea9; 67 | } 68 | 69 | .command-key { 70 | color: #cda869; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/Content/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import TabBar from './TabBar' 5 | import KeyContent from './KeyContent' 6 | import Terminal from './Terminal' 7 | import Config from './Config' 8 | import Footer from './Footer' 9 | 10 | class Content extends React.PureComponent { 11 | constructor() { 12 | super() 13 | this.state = { 14 | pattern: '', 15 | db: 0, 16 | version: 0, 17 | tab: 'Content' 18 | } 19 | } 20 | 21 | init(keyName) { 22 | this.setState({keyType: null}) 23 | if (keyName !== null) { 24 | this.setState({keyType: null}) 25 | this.props.redis.type(keyName).then(keyType => { 26 | if (keyName === this.props.keyName) { 27 | this.setState({keyType}) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | componentDidMount() { 34 | this.init(this.props.keyName) 35 | } 36 | 37 | componentWillReceiveProps(nextProps) { 38 | if (nextProps.keyName !== this.props.keyName || nextProps.version !== this.props.version) { 39 | this.init(nextProps.keyName) 40 | } 41 | if (nextProps.metaVersion !== this.props.metaVersion) { 42 | this.setState({version: this.state.version + 1}) 43 | } 44 | } 45 | 46 | handleTabChange(tab) { 47 | this.setState({tab}) 48 | } 49 | 50 | render() { 51 | return (
      52 | 56 | { 63 | this.setState({version: this.state.version + 1}) 64 | }} 65 | /> 66 | 73 | 79 |
      85 |
      ) 86 | } 87 | } 88 | 89 | export default Content 90 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/ContentEditable/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import escape from 'lodash.escape' 4 | 5 | require('./index.scss') 6 | 7 | export default class ContentEditable extends React.Component { 8 | constructor() { 9 | super() 10 | } 11 | 12 | render() { 13 | const {html, enabled, ...props} = this.props 14 | return (
      17 | 25 |
      ) 26 | } 27 | 28 | shouldComponentUpdate(nextProps) { 29 | return nextProps.html !== this.props.html || // ReactDOM.findDOMNode(this.refs.text).innerHTML || 30 | nextProps.enabled !== this.props.enabled 31 | } 32 | 33 | componentDidMount() { 34 | if (this.props.enabled) { 35 | ReactDOM.findDOMNode(this.refs.text).focus() 36 | } 37 | } 38 | 39 | componentDidUpdate() { 40 | const node = ReactDOM.findDOMNode(this.refs.text) 41 | if (escape(this.props.html) !== node.innerHTML) { 42 | node.innerHTML = this.props.html 43 | } 44 | if (this.props.enabled) { 45 | const range = document.createRange() 46 | range.selectNodeContents(node) 47 | const sel = window.getSelection() 48 | sel.removeAllRanges() 49 | sel.addRange(range) 50 | } 51 | } 52 | 53 | handleKeyDown(evt) { 54 | if (evt.keyCode === 13) { 55 | ReactDOM.findDOMNode(this.refs.text).blur() 56 | evt.preventDefault() 57 | evt.stopPropagation() 58 | return 59 | } 60 | if (evt.keyCode === 27) { 61 | this.props.onChange(this.props.html) 62 | evt.preventDefault() 63 | evt.stopPropagation() 64 | } 65 | } 66 | 67 | handleChange(evt) { 68 | const html = ReactDOM.findDOMNode(this.refs.text).innerHTML 69 | if (html !== this.lastHtml) { 70 | evt.target = {value: html} 71 | } 72 | this.lastHtml = html 73 | } 74 | 75 | handleSubmit() { 76 | this.props.onChange(ReactDOM.findDOMNode(this.refs.text).textContent) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/ContentEditable/index.scss: -------------------------------------------------------------------------------- 1 | .ContentEditable { 2 | [contenteditable="true"] { 3 | background: #fff !important; 4 | color: #333; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/KeyBrowser/Footer.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | 5 | class Footer extends React.Component { 6 | constructor() { 7 | super() 8 | this.state = {} 9 | } 10 | 11 | componentDidMount() { 12 | this.updateInfo() 13 | this.updateDBCount() 14 | this.interval = setInterval(this.updateInfo.bind(this), 10000) 15 | } 16 | 17 | componentWillReceiveProps(nextProps) { 18 | if (nextProps.db !== this.props.db) { 19 | this.updateInfo() 20 | } 21 | } 22 | 23 | updateDBCount() { 24 | this.props.redis.config('get', 'databases', (err, res) => { 25 | if (!err && res[1]) { 26 | this.setState({databases: Number(res[1])}) 27 | } else { 28 | const redis = this.props.redis.duplicate() 29 | const select = redis.select.bind(redis) 30 | this.guessDatabaseNumber(select, 15).then(count => { 31 | return typeof count === 'number' ? count : this.guessDatabaseNumber(select, 1, 0) 32 | }).then(count => { 33 | this.setState({databases: count + 1}) 34 | }) 35 | } 36 | }) 37 | } 38 | 39 | updateInfo() { 40 | this.props.redis.info((err, res) => { 41 | if (err) { 42 | return 43 | } 44 | const info = {} 45 | 46 | const lines = res.split('\r\n') 47 | for (let i = 0; i < lines.length; i++) { 48 | const parts = lines[i].split(':') 49 | if (parts[1]) { 50 | info[parts[0]] = parts[1] 51 | } 52 | } 53 | 54 | this.setState(info) 55 | }) 56 | } 57 | 58 | guessDatabaseNumber(select, startIndex, lastSuccessIndex) { 59 | if (startIndex > 30) { 60 | return Promise.resolve(30) 61 | } 62 | return select(startIndex) 63 | .then(() => { 64 | return this.guessDatabaseNumber(select, startIndex + 1, startIndex) 65 | }).catch(err => { 66 | if (typeof lastSuccessIndex === 'number') { 67 | return lastSuccessIndex 68 | } 69 | return null 70 | }) 71 | } 72 | 73 | componentWillUnmount() { 74 | clearInterval(this.interval) 75 | this.interval = null 76 | } 77 | 78 | handleChange(evt) { 79 | const db = Number(evt.target.value) 80 | this.props.onDatabaseChange(db) 81 | } 82 | 83 | keyCountByDb(dbNumber){ 84 | const db = `db${dbNumber}` 85 | let keys = 0 86 | if (this.state[db]) { 87 | const match = this.state[db].match(/keys=(\d+)/) 88 | if (match) { 89 | keys = match[1] 90 | } 91 | } 92 | return keys 93 | } 94 | 95 | render() { 96 | const keys = this.keyCountByDb(this.props.db) 97 | return (
      98 | Keys: {keys} 99 |
      100 | DB: 101 | 122 |
      123 |
      ) 124 | } 125 | } 126 | 127 | export default Footer 128 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/KeyBrowser/KeyList/index.scss: -------------------------------------------------------------------------------- 1 | .pattern-table { 2 | position: relative; 3 | overflow: hidden; 4 | &:focus { 5 | outline: 0; 6 | } 7 | 8 | footer { 9 | height: 24px; 10 | } 11 | } 12 | 13 | .public_fixedDataTable_bottomShadow { 14 | display: none; 15 | } 16 | 17 | .key-type { 18 | margin: 4px 0 0; 19 | padding: 0 !important; 20 | text-transform: uppercase; 21 | width: 32px; 22 | height: 16px; 23 | font-size: 11px !important; 24 | line-height: 17px !important; 25 | display: block; 26 | text-align: center; 27 | background: #60d4ca; 28 | color: #fff; 29 | 30 | &.str { background: #5dc936; } 31 | &.list { background: #fca32a; } 32 | &.hash { background: #b865d0; } 33 | &.zset { background: #fa5049; } 34 | &.set { background: #239ff2; } 35 | } 36 | 37 | .public_fixedDataTable_header, .public_fixedDataTableRow_main.is-loading { 38 | .public_fixedDataTableCell_main { 39 | font-family: system, -apple-system, ".SFNSDisplay-Regular", "Helvetica Neue", Helvetica, "Segoe UI", sans-serif !important; 40 | } 41 | } 42 | 43 | .public_fixedDataTableCell_cellContent { 44 | padding: 0; 45 | } 46 | 47 | .public_fixedDataTableCell_main { 48 | font-family: Consolas, monospace; 49 | font-size: 12px; 50 | line-height: 24px; 51 | padding: 0 8px; 52 | } 53 | 54 | .public_fixedDataTableRow_main { 55 | color: #606061; 56 | } 57 | 58 | :focus .public_fixedDataTableRow_main.is-selected { 59 | background: #116cd6; 60 | color: #fff; 61 | 62 | .public_fixedDataTableCell_main { 63 | background: transparent; 64 | } 65 | } 66 | 67 | .public_fixedDataTableRow_main.is-selected { 68 | background: #dcdcdc; 69 | 70 | .public_fixedDataTableCell_main { 71 | background: transparent; 72 | } 73 | } 74 | 75 | .public_fixedDataTableCell_main { 76 | border: none; 77 | } 78 | 79 | .public_fixedDataTable_main { 80 | border-right: none; 81 | border-left: none; 82 | border-bottom: none; 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/KeyBrowser/PatternList/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import {ipcRenderer} from 'electron' 5 | 6 | require('./index.scss') 7 | 8 | class PatternList extends React.Component { 9 | constructor(props) { 10 | super() 11 | this.state = { 12 | patternDropdown: false, 13 | pattern: props.pattern 14 | } 15 | } 16 | 17 | componentWillReceiveProps(nextProps) { 18 | if (nextProps.db !== this.props.db) { 19 | this.updatePattern('') 20 | } 21 | if (nextProps.pattern !== this.props.pattern) { 22 | this.setState({pattern: nextProps.pattern}) 23 | } 24 | } 25 | 26 | updatePattern(value) { 27 | this.setState({pattern: value}) 28 | this.props.onChange(value) 29 | } 30 | 31 | render() { 32 | return (
      33 | 34 | { 40 | this.updatePattern(evt.target.value) 41 | }} 42 | /> 43 | { 46 | this.setState({patternDropdown: !this.state.patternDropdown}) 47 | }} 48 | /> 49 |
      53 |
        54 | { 55 | this.props.patterns.map(pattern => { 56 | return (
      • { 58 | const value = pattern.get('value') 59 | this.props.onChange(value) 60 | this.setState({patternDropdown: false, pattern: value}) 61 | }} 62 | >{pattern.get('name')}
      • ) 63 | }) 64 | } 65 |
      • { 68 | ipcRenderer.send('create patternManager', `${this.props.connectionKey}|${this.props.db}`) 69 | }} 70 | > 71 | 72 | Manage Patterns... 73 |
      • 74 |
      75 |
      76 |
      ) 77 | } 78 | } 79 | 80 | export default PatternList 81 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/KeyBrowser/PatternList/index.scss: -------------------------------------------------------------------------------- 1 | .pattern-input { 2 | position: relative; 3 | padding: 6px; 4 | .icon-search { 5 | position: absolute; 6 | left: 14px; 7 | top: 12px; 8 | opacity: 0.5; 9 | } 10 | .icon-down-open { 11 | position: absolute; 12 | right: 0; 13 | top: 0; 14 | opacity: 0.5; 15 | transition: 0.1s; 16 | display: inline-block; 17 | width: 40px; 18 | text-align: center; 19 | height: 42px; 20 | line-height: 42px; 21 | 22 | &.is-active { 23 | transform: rotate(180deg); 24 | } 25 | } 26 | 27 | input { 28 | padding-left: 22px; 29 | } 30 | } 31 | 32 | .pattern-dropdown { 33 | position: absolute; 34 | z-index: 999; 35 | background: #fff; 36 | margin-top: 6px; 37 | left: 0; 38 | width: 100%; 39 | box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.21); 40 | transition: transform 125ms cubic-bezier(0.18, 0.89, 0.32, 1.12), opacity 100ms linear; 41 | transform-origin: top; 42 | transform: scale(1, 0.2); 43 | pointer-events: none; 44 | opacity: 0; 45 | 46 | &.is-active { 47 | pointer-events: initial; 48 | opacity: 1; 49 | transform: scale(1, 1); 50 | } 51 | 52 | ul { 53 | height: 100%; 54 | overflow: auto; 55 | } 56 | 57 | li { 58 | display: block; 59 | padding: 8px 12px; 60 | font-family: Consolas, monospace; 61 | border-top: 1px solid #f5f5f4; 62 | 63 | &:hover { 64 | background: #116cd6; 65 | color: #fff; 66 | } 67 | 68 | &:last-child { 69 | font-family: system, -apple-system, ".SFNSDisplay-Regular", "Helvetica Neue", Helvetica, "Segoe UI", sans-serif; 70 | } 71 | } 72 | } 73 | .manage-pattern-button { 74 | color: #116cd6; 75 | 76 | span.icon { 77 | margin-right: 5px; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/KeyBrowser/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {memo} from 'react' 4 | import {List} from 'immutable' 5 | import PatternList from './PatternList' 6 | import KeyList from './KeyList' 7 | import Footer from './Footer' 8 | 9 | const FOOTER_HEIGHT = 66 10 | 11 | function KeyBrowser({ 12 | pattern, patterns, connectionKey, db, height, width, redis, 13 | onPatternChange, onCreateKey, onKeyMetaChange, onSelectKey, onDatabaseChange 14 | }) { 15 | const clientHeight = height - FOOTER_HEIGHT 16 | return (
      17 | 25 | 35 |
      36 |
      ) 37 | } 38 | 39 | export default memo(KeyBrowser) 40 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React from 'react' 4 | import {connect} from 'react-redux' 5 | import SplitPane from 'react-split-pane' 6 | import KeyBrowser from './KeyBrowser' 7 | import Content from './Content' 8 | require('./index.scss') 9 | 10 | class Database extends React.PureComponent { 11 | constructor() { 12 | super() 13 | this.$window = $(window) 14 | 15 | this.state = { 16 | sidebarWidth: 260, 17 | key: null, 18 | db: 0, 19 | version: 0, 20 | metaVersion: 0, 21 | pattern: '', 22 | clientHeight: this.$window.height() - $('#tabGroupWrapper').height() 23 | } 24 | } 25 | 26 | componentDidMount() { 27 | this.updateLayoutBinded = this.updateLayout.bind(this) 28 | $(window).on('resize', this.updateLayoutBinded) 29 | this.updateLayout() 30 | } 31 | 32 | componentWillUnmount() { 33 | $(window).off('resize', this.updateLayoutBinded) 34 | } 35 | 36 | updateLayout() { 37 | this.setState({ 38 | clientHeight: this.$window.height() - $('#tabGroupWrapper').height() 39 | }) 40 | } 41 | 42 | handleCreateKey(key) { 43 | this.setState({key, pattern: key}) 44 | } 45 | 46 | render() { 47 | return ( { 54 | this.setState({sidebarWidth: size}) 55 | }} 56 | > 57 | this.setState({pattern})} 61 | height={this.state.clientHeight} 62 | width={this.state.sidebarWidth} 63 | redis={this.props.redis} 64 | connectionKey={this.props.connectionKey} 65 | onSelectKey={key => this.setState({key, version: this.state.version + 1})} 66 | onCreateKey={this.handleCreateKey.bind(this)} 67 | db={this.state.db} 68 | onDatabaseChange={db => this.setState({db})} 69 | onKeyMetaChange={() => this.setState({metaVersion: this.state.metaVersion + 1})} 70 | /> 71 | this.setState({db})} 80 | /> 81 | ) 82 | } 83 | } 84 | 85 | function mapStateToProps(state, {instance}) { 86 | return { 87 | patterns: state.patterns, 88 | redis: instance.get('redis'), 89 | connectionKey: instance.get('connectionKey') 90 | } 91 | } 92 | 93 | export default connect(mapStateToProps)(Database) 94 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/DatabaseContainer/index.scss: -------------------------------------------------------------------------------- 1 | .Resizer { 2 | background: #000; 3 | opacity: .2; 4 | z-index: 1; 5 | -moz-box-sizing: border-box; 6 | -webkit-box-sizing: border-box; 7 | box-sizing: border-box; 8 | -moz-background-clip: padding; 9 | -webkit-background-clip: padding; 10 | background-clip: padding-box; 11 | } 12 | 13 | .Resizer.horizontal { 14 | height: 11px; 15 | margin: -5px 0; 16 | border-top: 5px solid rgba(255, 255, 255, 0); 17 | border-bottom: 5px solid rgba(255, 255, 255, 0); 18 | cursor: row-resize; 19 | width: 100%; 20 | } 21 | 22 | .Resizer.vertical { 23 | width: 11px; 24 | margin: 0 -5px; 25 | border-left: 5px solid rgba(255, 255, 255, 0); 26 | border-right: 5px solid rgba(255, 255, 255, 0); 27 | cursor: col-resize; 28 | height: 100%; 29 | } 30 | 31 | .overflow-wrapper { 32 | display: flex; 33 | width: calc(100% + 16px); 34 | margin-left: -8px; 35 | 36 | span { 37 | padding: 0 8px; 38 | display: block; 39 | flex: 1; 40 | width: 100%; 41 | white-space: nowrap; 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | } 45 | 46 | span[contenteditable="true"] { 47 | text-overflow: clip; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/Modal/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/windows/MainWindow/InstanceContent/Modal/icon.png -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/Modal/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | require('json-editor') 4 | 5 | require('./index.scss') 6 | 7 | export default class Modal extends React.Component { 8 | handleSubmit() { 9 | if (this.editor) { 10 | const errors = this.editor.validate() 11 | if (errors.length) { 12 | $('.ui-state-error', ReactDOM.findDOMNode(this.refs.form)).css('opacity', 1) 13 | return 14 | } 15 | this.props.onSubmit(this.editor.getValue()) 16 | } else { 17 | this.props.onSubmit(1) 18 | } 19 | } 20 | 21 | handleCancel() { 22 | this.props.onCancel() 23 | } 24 | 25 | componentDidMount() { 26 | if (this.props.form) { 27 | this.editor = new JSONEditor(ReactDOM.findDOMNode(this.refs.form), { 28 | disable_array_add: true, 29 | disable_array_delete: true, 30 | disable_array_reorder: true, 31 | disable_collapse: true, 32 | disable_edit_json: true, 33 | disable_properties: true, 34 | required_by_default: true, 35 | schema: this.props.form, 36 | show_errors: 'always', 37 | theme: 'jqueryui' 38 | }) 39 | 40 | $('.row input, .row select', ReactDOM.findDOMNode(this.refs.form)).first().focus() 41 | } else { 42 | $('.nt-button', ReactDOM.findDOMNode(this)).first().focus() 43 | } 44 | } 45 | 46 | handleKeyDown(evt) { 47 | if (evt.keyCode === 9) { 48 | const $all = $('.row input, .row select, .nt-button', ReactDOM.findDOMNode(this)) 49 | const focused = $(':focus')[0] 50 | let i 51 | for (i = 0; i < $all.length - 1; ++i) { 52 | if ($all[i] != focused) { 53 | continue 54 | } 55 | $all[i + 1].focus() 56 | $($all[i + 1]).select() 57 | break 58 | } 59 | // Must have been focused on the last one or none of them. 60 | if (i == $all.length - 1) { 61 | $all[0].focus() 62 | $($all[0]).select() 63 | } 64 | evt.stopPropagation() 65 | evt.preventDefault() 66 | return 67 | } 68 | if (evt.keyCode === 27) { 69 | this.handleCancel() 70 | evt.stopPropagation() 71 | evt.preventDefault() 72 | return 73 | } 74 | if (evt.keyCode === 13) { 75 | const node = ReactDOM.findDOMNode(this.props.form ? this.refs.cancel : this.refs.submit) 76 | node.focus() 77 | setTimeout(() => { 78 | node.click() 79 | }, 10) 80 | evt.stopPropagation() 81 | evt.preventDefault() 82 | } 83 | } 84 | 85 | render() { 86 | return (
      91 |
      92 | { 93 | this.props.title &&
      94 | {this.props.title} 95 |
      96 | } 97 |
      98 | {!this.props.form &&
      } 99 | {this.props.content} 100 |
      101 |
      102 |
      103 | 108 | 113 |
      114 |
      115 |
      ) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/Modal/index.scss: -------------------------------------------------------------------------------- 1 | .Modal { 2 | position: fixed; 3 | left: 0; 4 | width: 100%; 5 | z-index: 999; 6 | height: calc(100% + 100px); 7 | margin-top: -100px; 8 | } 9 | 10 | .Modal__title { 11 | font-size: 14px; 12 | font-weight: bold; 13 | margin-bottom: 10px; 14 | } 15 | 16 | .Modal__content { 17 | position: relative; 18 | width: 420px; 19 | background: #efefef; 20 | border: 1px solid #a3a3a3; 21 | border-top: 0; 22 | 23 | padding: 18px 20px 18px 100px; 24 | box-shadow: inset 1px 4px 9px -6px, 0 5px 20px rgba(0, 0, 0, 0.3); 25 | 26 | margin: 100px auto 0; 27 | 28 | font-size: 12px; 29 | 30 | .nt-button-group { 31 | margin-top: 20px; 32 | } 33 | 34 | * { 35 | -webkit-user-select: text; 36 | } 37 | } 38 | 39 | .Modal__icon { 40 | position: absolute; 41 | left: 20px; 42 | top: 22px; 43 | width: 62px; 44 | height: 57px; 45 | background: transparent url(./warning.png) left top no-repeat; 46 | background-size: 62px 57px; 47 | 48 | span { 49 | position: absolute; 50 | bottom: -6px; 51 | right: -6px; 52 | width: 34px; 53 | height: 34px; 54 | background: transparent url(./icon.png) left top no-repeat; 55 | background-size: 34px 34px; 56 | } 57 | } 58 | 59 | .Modal__form { 60 | h3 { 61 | display: none; 62 | } 63 | 64 | .ui-corner-all { 65 | padding: 0 !important; 66 | margin: 0 !important; 67 | } 68 | 69 | .form-control { 70 | position: relative; 71 | background: none; 72 | border: 0; 73 | padding: 4px 0px 14px !important; 74 | 75 | label { 76 | position: absolute; 77 | left: -90px; 78 | text-align: right; 79 | width: 80px; 80 | font-weight: normal !important; 81 | } 82 | 83 | input, select { 84 | width: 100% !important; 85 | margin: 0 !important; 86 | } 87 | 88 | .ui-state-error { 89 | position: absolute; 90 | top: 25px; 91 | opacity: 0; 92 | } 93 | } 94 | 95 | .ui-state-error { 96 | color: #ff2a1c; 97 | font-size: 12px; 98 | } 99 | 100 | // input, select { 101 | // width: 100%; 102 | // min-height: 25px; 103 | // padding: 5px 10px; 104 | // line-height: 1.6; 105 | // background-color: #fff; 106 | // border: 1px solid #ddd; 107 | // outline: 0; 108 | 109 | // &:focus { 110 | // border-radius: 4px; 111 | // border-color: #6db3fd; 112 | // box-shadow: 3px 3px 0 #6db3fd, -3px -3px 0 #6db3fd, -3px 3px 0 #6db3fd, 3px -3px 0 #6db3fd; 113 | // } 114 | // } 115 | } 116 | 117 | .modal-enter { 118 | .Modal__content { 119 | transform: translateY(-100%); 120 | } 121 | } 122 | 123 | .modal-enter.modal-enter-active { 124 | .Modal__content { 125 | transform: translateY(0); 126 | transition: transform 150ms linear; 127 | } 128 | } 129 | 130 | .modal-leave { 131 | .Modal__content { 132 | transform: translateY(0); 133 | } 134 | } 135 | 136 | .modal-leave.modal-leave-active { 137 | .Modal__content { 138 | transform: translateY(-100%); 139 | transition: transform 150ms linear; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/Modal/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luin/medis/12c87a5a2cc3fd7fa616beb2eaed79413538769a/src/renderer/windows/MainWindow/InstanceContent/Modal/warning.png -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceContent/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {PureComponent} from 'react' 4 | import ConnectionSelectorContainer from './ConnectionSelectorContainer' 5 | import DatabaseContainer from './DatabaseContainer' 6 | import Modal from './Modal' 7 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group' 8 | 9 | class InstanceContent extends PureComponent { 10 | constructor() { 11 | super() 12 | this.state = {} 13 | } 14 | 15 | componentDidMount() { 16 | window.showModal = modal => { 17 | this.activeElement = document.activeElement 18 | this.setState({modal}) 19 | 20 | return new Promise((resolve, reject) => { 21 | this.promise = {resolve, reject} 22 | }) 23 | } 24 | } 25 | 26 | modalSubmit(result) { 27 | this.promise.resolve(result) 28 | this.setState({modal: null}) 29 | if (this.activeElement) { 30 | this.activeElement.focus() 31 | } 32 | } 33 | 34 | modalCancel() { 35 | this.promise.reject() 36 | this.setState({modal: null}) 37 | if (this.activeElement) { 38 | this.activeElement.focus() 39 | } 40 | } 41 | 42 | componentWillUnmount() { 43 | delete window.showModal 44 | } 45 | 46 | render() { 47 | const {instances, activeInstanceKey} = this.props 48 | const contents = instances.map(instance => ( 49 |
      53 | { 54 | instance.get('redis') 55 | ? 56 | : 57 | } 58 |
      59 | )) 60 | 61 | return ( 62 |
      63 | 68 | { 69 | this.state.modal && 70 | 76 | } 77 | 78 | {contents} 79 |
      80 | ) 81 | } 82 | } 83 | 84 | export default InstanceContent 85 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceTabs/Tab.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react' 2 | import {SortableElement} from 'react-sortable-hoc' 3 | 4 | interface ITabProps { 5 | instanceKey: string, 6 | title?: string, 7 | active: boolean, 8 | onTabClick: (key: string) => void, 9 | onTabCloseButtonClick: (key: string) => void 10 | } 11 | 12 | function Tab({instanceKey, onTabClick, onTabCloseButtonClick, active, title = 'Quick Connect'}: ITabProps) { 13 | return
      { 15 | onTabClick(instanceKey) 16 | }} 17 | className={active ? 'tab-item active' : 'tab-item'} 18 | > 19 | {title} 20 | onTabCloseButtonClick(instanceKey)}> 23 |
      24 | } 25 | 26 | export default memo(SortableElement(Tab)) 27 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceTabs/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react' 2 | import {SortableContainer} from 'react-sortable-hoc' 3 | import Tab from './Tab' 4 | 5 | interface ITabsProps { 6 | instances: any 7 | activeInstanceKey: string 8 | onTabSelect: (key: string) => void 9 | onTabClose: (key: string) => void 10 | 11 | } 12 | function Tabs({instances, activeInstanceKey, onTabSelect, onTabClose}: ITabsProps) { 13 | return ( 14 |
      15 | {instances.map((instance, index) => { 16 | const key = instance.get('key') 17 | return 26 | })} 27 |
      28 | ) 29 | } 30 | 31 | export default memo(SortableContainer(Tabs)) 32 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceTabs/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {memo} from 'react' 2 | import Tabs from './Tabs' 3 | 4 | require('./main.scss') 5 | 6 | function isModalShown() { 7 | return $('.Modal').length > 0 8 | } 9 | 10 | let display = 'flex' 11 | 12 | interface IInstanceTabsProps { 13 | instances: any 14 | activeInstanceKey: string 15 | onCreateInstance: any 16 | onSelectInstance: any 17 | onDelInstance: any 18 | onMoveInstance: any 19 | } 20 | 21 | function InstanceTabs({ 22 | onCreateInstance, onSelectInstance, onDelInstance, instances, activeInstanceKey, onMoveInstance 23 | }: IInstanceTabsProps) { 24 | const handleAddButtonClick = () => { 25 | if (!isModalShown()) { 26 | onCreateInstance() 27 | } 28 | } 29 | 30 | const handleTabSelect = (key: string) => { 31 | if (!isModalShown()) { 32 | onSelectInstance(key) 33 | } 34 | } 35 | 36 | const handleTabClose = (key: string) => { 37 | if (!isModalShown()) { 38 | onDelInstance(key) 39 | } 40 | } 41 | 42 | const currentDisplay = instances.count() === 1 ? 'none' : 'flex' 43 | if (display !== currentDisplay) { 44 | display = currentDisplay 45 | setTimeout(() => $(window).trigger('resize'), 0) 46 | } 47 | 48 | return
      49 |
      50 | { 61 | if (oldIndex !== newIndex) { 62 | onMoveInstance(instances.getIn([oldIndex, 'key']), instances.getIn([newIndex, 'key'])) 63 | } 64 | }} 65 | shouldCancelStart={(e) => (e.target as any).nodeName.toUpperCase() === 'SPAN'} 66 | /> 67 |
      68 | {'+'} 69 |
      70 |
      71 |
      72 | } 73 | 74 | export default memo(InstanceTabs) 75 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/InstanceTabs/main.scss: -------------------------------------------------------------------------------- 1 | .instance-tabs { 2 | * { 3 | -webkit-user-select: none; 4 | } 5 | display: flex; 6 | 7 | li { 8 | position: relative; 9 | flex-grow: 1; 10 | background: #bebebe; 11 | color: #424242; 12 | border: 1px solid #a0a0a0; 13 | border-right: none; 14 | text-align: center; 15 | line-height: 22px; 16 | cursor: default; 17 | 18 | &:first-child { 19 | border-left: none; 20 | } 21 | 22 | &.is-active, 23 | &.is-active:hover { 24 | background: #d3d3d3; 25 | color: #000000; 26 | border-top-color: #d3d3d3; 27 | 28 | .rdTabCloseIcon:hover { 29 | background: #c0c0c0; 30 | color: #6c6c6c; 31 | } 32 | } 33 | 34 | &:hover { 35 | background: #b2b2b2; 36 | color: #3e3e3e; 37 | .rdTabCloseIcon { 38 | display: block; 39 | } 40 | } 41 | } 42 | } 43 | 44 | .instance-tabs__add { 45 | flex-grow: 0 !important; 46 | flex-basis: 24px; 47 | &:hover { 48 | background: #bebebe !important; 49 | color: #424242 !important; 50 | } 51 | } 52 | 53 | .rdTabCloseIcon { 54 | position: absolute; 55 | left: 4px; 56 | top: 4px; 57 | display: none; 58 | border-radius: 2px; 59 | line-height: 1em; 60 | width: 14px; 61 | height: 14px; 62 | 63 | &:hover { 64 | color: #5b5b5b; 65 | background: #a2a2a2; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/entry.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('../../photon/css/photon.min.css') 4 | require('../../../../node_modules/fixed-data-table-contextmenu/dist/fixed-data-table.css') 5 | 6 | import ReactDOM from 'react-dom' 7 | import MainWindow from './' 8 | import {ipcRenderer} from 'electron' 9 | import store from 'Redux/store' 10 | import * as actions from 'Redux/actions' 11 | 12 | require('../../styles/global.scss') 13 | 14 | window.$ = window.jQuery = require('jquery'); 15 | window.Buffer = global.Buffer; 16 | 17 | ipcRenderer.on('action', (evt, action) => { 18 | if ($('.Modal').length && action.indexOf('Instance') !== -1) { 19 | return 20 | } 21 | 22 | store.skipPersist = true 23 | store.dispatch(actions[action]()) 24 | store.skipPersist = false 25 | }) 26 | 27 | ReactDOM.render(MainWindow, document.body.appendChild(document.createElement('div'))) 28 | -------------------------------------------------------------------------------- /src/renderer/windows/MainWindow/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import React, {PureComponent} from 'react' 4 | import {createSelector} from 'reselect' 5 | import {Provider, connect} from 'react-redux' 6 | import InstanceTabs from './InstanceTabs' 7 | import InstanceContent from './InstanceContent' 8 | import DocumentTitle from 'react-document-title' 9 | import {createInstance, selectInstance, delInstance, moveInstance} from 'Redux/actions' 10 | import store from 'Redux/store' 11 | 12 | class MainWindow extends PureComponent { 13 | componentDidMount() { 14 | $(window).on('keydown.redis', this.onHotKey.bind(this)) 15 | } 16 | 17 | componentWillUnmount() { 18 | $(window).off('keydown.redis') 19 | } 20 | 21 | onHotKey(e) { 22 | const {instances, selectInstance} = this.props 23 | if (!e.ctrlKey && e.metaKey) { 24 | const code = e.keyCode 25 | if (code >= 49 && code <= 57) { 26 | const number = code - 49 27 | if (number === 8) { 28 | const instance = instances.get(instances.count() - 1) 29 | if (instance) { 30 | selectInstance(instance.get('key')) 31 | return false 32 | } 33 | } else { 34 | const instance = instances.get(number) 35 | if (instance) { 36 | selectInstance(instance.get('key')) 37 | return false 38 | } 39 | } 40 | } 41 | } 42 | return true 43 | } 44 | 45 | getTitle() { 46 | const {activeInstance} = this.props 47 | if (!activeInstance) { 48 | return '' 49 | } 50 | const version = activeInstance.get('version') 51 | ? `(Redis ${activeInstance.get('version')}) ` 52 | : '' 53 | 54 | return version + activeInstance.get('title') 55 | } 56 | 57 | render() { 58 | const {instances, activeInstance, createInstance, 59 | selectInstance, delInstance, moveInstance} = this.props 60 | 61 | return ( 62 |
      63 | 71 | 75 |
      76 |
      ) 77 | } 78 | } 79 | 80 | const selector = createSelector( 81 | state => state.instances, 82 | state => state.activeInstanceKey, 83 | (instances, activeInstanceKey) => { 84 | return { 85 | instances, 86 | activeInstance: instances.find(instance => instance.get('key') === activeInstanceKey) 87 | } 88 | } 89 | ) 90 | 91 | const mapDispatchToProps = { 92 | createInstance, 93 | selectInstance, 94 | delInstance, 95 | moveInstance 96 | } 97 | 98 | const MainWindowContainer = connect(selector, mapDispatchToProps)(MainWindow) 99 | 100 | export default 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/renderer/windows/PatternManagerWindow/app.scss: -------------------------------------------------------------------------------- 1 | .patternList { 2 | background: #fff; 3 | border: 1px solid #c5c5c5; 4 | width: 210px; 5 | position: absolute; 6 | top: 20px; 7 | left: 20px; 8 | height: 236px; 9 | 10 | footer { 11 | position: absolute; 12 | bottom: 0; 13 | left: 0; 14 | width: 208px; 15 | height: 19px; 16 | background: #fafafa; 17 | border-top: 1px solid #b4b4b4; 18 | button { 19 | width: 23px; 20 | height: 18px; 21 | border-radius: 0; 22 | padding: 0; 23 | border-left: 1px solid #b4b4b4; 24 | border-right: 1px solid #b4b4b4; 25 | margin-left: -1px; 26 | border-top: 0; 27 | border-bottom: 0; 28 | background: #fafafa; 29 | 30 | &.is-disabled { 31 | color: #bfbfbf; 32 | } 33 | } 34 | } 35 | 36 | .nav-group-item:active { 37 | background-color: #116cd6 !important; 38 | color: #fff; 39 | } 40 | } 41 | 42 | .nav-group-item { 43 | padding: 0 5px; 44 | 45 | &.sortable-chosen { 46 | color: #000; 47 | 48 | &.is-active { 49 | background: #116cd6 !important; 50 | color: #fff; 51 | } 52 | } 53 | 54 | &.is-active { 55 | background: #116cd6; 56 | color: #fff; 57 | } 58 | } 59 | 60 | .form { 61 | position: absolute !important; 62 | right: 20px; 63 | top: 20px; 64 | width: 328px; 65 | height: 236px; 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/windows/PatternManagerWindow/entry.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('../../photon/css/photon.min.css') 4 | 5 | import React from 'react' 6 | import ReactDOM from 'react-dom' 7 | import {Provider} from 'react-redux' 8 | import PatternManagerWindow from './' 9 | import store from 'Redux/store' 10 | import * as actions from 'Redux/actions' 11 | import {remote, ipcRenderer} from 'electron' 12 | 13 | require('../../styles/global.scss') 14 | 15 | window.$ = window.jQuery = require('jquery'); 16 | 17 | ipcRenderer.on('action', (evt, action) => { 18 | if (type === 'delInstance') { 19 | remote.getCurrentWindow().close() 20 | return 21 | } 22 | 23 | store.skipPersist = true 24 | store.dispatch(actions[action]()) 25 | store.skipPersist = false 26 | }) 27 | 28 | ReactDOM.render( 29 | 30 | 31 | , 32 | document.body.appendChild(document.createElement('div')) 33 | ) 34 | -------------------------------------------------------------------------------- /src/renderer/windows/PatternManagerWindow/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {connect} from 'react-redux' 3 | import {createPattern, updatePattern, removePattern} from 'Redux/actions' 4 | import {List} from 'immutable' 5 | 6 | require('./app.scss') 7 | 8 | const connectionKey = getParameterByName('arg') 9 | 10 | class App extends React.Component { 11 | constructor(props, context) { 12 | super(props, context) 13 | this.state = {index: 0} 14 | } 15 | 16 | handleChange(property, e) { 17 | this.setState({[property]: e.target.value}) 18 | } 19 | 20 | select(index) { 21 | this.setState({ 22 | index, 23 | name: null, 24 | value: null 25 | }) 26 | } 27 | 28 | renderPatternForm(activePattern) { 29 | if (!activePattern) { 30 | return null 31 | } 32 | return
      37 |
      38 | 39 | 45 |
      46 |
      47 | 48 | 54 |
      55 |
      56 | 67 |
      68 |
      69 | } 70 | 71 | render() { 72 | const {patterns, createPattern, removePattern} = this.props 73 | const activePattern = patterns.get(this.state.index) 74 | return (
      75 |
      76 |
      { 77 | patterns.map((pattern, index) => { 78 | return ( this.select(index)} 82 | > 83 | {pattern.get('name')} 84 | ) 85 | }) 86 | }
      87 | 105 |
      106 | {this.renderPatternForm(activePattern)} 107 |
      ) 108 | } 109 | } 110 | 111 | function mapStateToProps(state) { 112 | return { 113 | patterns: state.patterns.get(connectionKey, List()) 114 | } 115 | } 116 | 117 | const mapDispatchToProps = { 118 | updatePattern, 119 | createPattern, 120 | removePattern 121 | } 122 | 123 | export default connect(mapStateToProps, mapDispatchToProps)(App) 124 | 125 | function getParameterByName(name) { 126 | name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]') 127 | const regex = new RegExp('[\\?&]' + name + '=([^&#]*)') 128 | const results = regex.exec(location.search) 129 | return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')) 130 | } 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "es6", 5 | "lib": [ 6 | "es6" 7 | ], 8 | "moduleResolution": "node", 9 | "types": [ 10 | "node" 11 | ], 12 | "typeRoots": [ 13 | "node_modules/@types" 14 | ], 15 | "module": "commonjs", 16 | "jsx": "preserve", 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true 19 | }, 20 | "include": [ 21 | "./src/**/*" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {resolve} = require('path') 4 | const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer') 5 | const HtmlWebpackPlugin = require('html-webpack-plugin') 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 7 | const {CheckerPlugin} = require('awesome-typescript-loader') 8 | const webpack = require('webpack') 9 | 10 | const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development' 11 | const watch = process.env.WEBPACK_WATCH === 'true' 12 | 13 | const distPath = resolve(__dirname, 'dist') 14 | 15 | const base = { 16 | mode, watch, 17 | output: { 18 | path: distPath, 19 | chunkFilename: '[name].chunk.js', 20 | filename: '[name].js' 21 | }, 22 | node: { 23 | Buffer: false, 24 | buffer: false, 25 | __dirname: false, 26 | __filename: false, 27 | }, 28 | module: { 29 | rules: [{ 30 | test: /\.(ts|tsx)$/, 31 | use: [{ 32 | loader: 'awesome-typescript-loader', 33 | options: { 34 | reportFiles: ['src/**/*.{ts,tsx}'], 35 | useCache: true, 36 | useBabel: true, 37 | babelCore: '@babel/core' 38 | } 39 | }], 40 | exclude: /node_modules/ 41 | }, { 42 | test: /\.(js|jsx)$/, 43 | exclude: /node_modules/, 44 | use: ['babel-loader'] 45 | }, { 46 | test: /\.scss$/, 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | 'css-loader', 50 | 'sass-loader' 51 | ] 52 | }, { 53 | test: /\.css$/, 54 | use: [ 55 | MiniCssExtractPlugin.loader, 56 | 'css-loader' 57 | ] 58 | }, { 59 | test: /\.(png|jpg)$/, 60 | use: [{ 61 | loader: "file-loader" 62 | }] 63 | }, { 64 | test: /\.(eot|woff|ttf)$/, 65 | use: [{ 66 | loader: "file-loader" 67 | }] 68 | }] 69 | }, 70 | externals: { 71 | 'system': '{}', // jsonlint 72 | 'file': '{}' // jsonlint 73 | }, 74 | } 75 | 76 | const renderPlugins = [ 77 | new HtmlWebpackPlugin({title: 'Medis', chunks: ['main'], filename: 'main.html'}), 78 | new HtmlWebpackPlugin({title: 'Manage Patterns', chunks: ['patternManager'], filename: 'patternManager.html'}), 79 | new MiniCssExtractPlugin({filename: '[name].css'}), 80 | new CheckerPlugin(), 81 | new webpack.ProvidePlugin({React: 'react'}), 82 | ] 83 | if (mode === 'production') { 84 | renderPlugins.push(new BundleAnalyzerPlugin()) 85 | } 86 | const renderer = Object.assign({}, base, { 87 | target: 'electron-renderer', 88 | output: Object.assign({}, base.output, { 89 | path: resolve(base.output.path, 'renderer') 90 | }), 91 | entry: { 92 | main: resolve(__dirname, 'src/renderer/windows/MainWindow/entry.jsx'), 93 | patternManager: resolve(__dirname, 'src/renderer/windows/PatternManagerWindow/entry.jsx') 94 | }, 95 | plugins: renderPlugins, 96 | resolve: { 97 | alias: { 98 | Redux: resolve(__dirname, 'src/renderer/redux/'), 99 | Utils: resolve(__dirname, 'src/renderer/utils/'), 100 | }, 101 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 102 | } 103 | }) 104 | 105 | const main = Object.assign({}, base, { 106 | target: 'electron-main', 107 | output: Object.assign({}, base.output, { 108 | path: resolve(base.output.path, 'main') 109 | }), 110 | entry: { 111 | index: resolve(__dirname, 'src/main/index.ts') 112 | }, 113 | resolve: { 114 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 115 | } 116 | }) 117 | 118 | module.exports = [main, renderer] 119 | --------------------------------------------------------------------------------