3 # Copyright (c) 2005 Fredrik Kuivinen <frekui@gmail.com>
4 # Copyright (c) 2005 Mark Williamson <mark.williamson@cl.cam.ac.uk>
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License version 2 as
8 # published by the Free Software Foundation.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 import sys
, math
, random
, qt
, os
, re
, signal
, sets
21 from optparse
import OptionParser
22 from commit
import CommitDialog
24 # Determine semantics according to executable name. Default to git.
25 if os
.path
.basename(sys
.argv
[0]) == 'hgct':
30 qconnect
= qt
.QObject
.connect
37 class MyListItem(qt
.QCheckListItem
):
38 def __init__(self
, parent
, file, commitMsg
= False):
39 qt
.QCheckListItem
.__init
__(self
, parent
, file.text
, qt
.QCheckListItem
.CheckBox
)
41 self
.commitMsg
= commitMsg
43 def compare(self
, item
, col
, asc
):
55 return cmp(self
.file.srcName
, item
.file.srcName
)
57 def paintCell(self
, p
, cg
, col
, w
, a
):
59 qt
.QListViewItem
.paintCell(self
, p
, cg
, col
, w
, a
)
61 qt
.QCheckListItem
.paintCell(self
, p
, cg
, col
, w
, a
)
64 return self
.state() == qt
.QCheckListItem
.On
66 def setSelected(self
, s
):
68 self
.setState(qt
.QCheckListItem
.On
)
70 self
.setState(qt
.QCheckListItem
.Off
)
72 class MyListView(qt
.QListView
):
73 def __init__(self
, parent
=None, name
=None):
74 qt
.QListView
.__init
__(self
, parent
, name
)
77 return ListViewIterator(self
)
79 class ListViewIterator
:
80 def __init__(self
, listview
):
81 self
.it
= qt
.QListViewItemIterator(listview
)
84 cur
= self
.it
.current()
97 class MainWidget(qt
.QMainWindow
):
98 def __init__(self
, options
, parent
=None, name
=None):
99 qt
.QMainWindow
.__init
__(self
, parent
, name
)
100 self
.setCaption(applicationName
)
103 splitter
= qt
.QSplitter(Qt
.Vertical
, self
)
104 self
.setCentralWidget(splitter
)
105 self
.splitter
= splitter
107 # The file list and file filter widgets are part of this layout widget.
108 self
.filesLayout
= qt
.QVBox(splitter
)
111 fW
= MyListView(self
.filesLayout
)
114 fW
.setSelectionMode(qt
.QListView
.NoSelection
)
115 fW
.addColumn('Description')
116 fW
.setResizeMode(qt
.QListView
.AllColumns
)
119 self
.filterLayout
= qt
.QHBox(self
.filesLayout
)
120 self
.filterClear
= qt
.QPushButton("&Clear", self
.filterLayout
)
121 self
.filterLabel
= qt
.QLabel(" File filter: ", self
.filterLayout
)
122 qconnect(self
.filterClear
, qt
.SIGNAL("clicked()"), self
.clearFilter
)
123 self
.filter = qt
.QLineEdit(self
.filterLayout
)
124 self
.filterLabel
.setBuddy(self
.filter)
126 qconnect(self
.filter, qt
.SIGNAL("textChanged(const QString&)"), self
.updateFilter
)
128 self
.newCurLambda
= lambda i
: self
.currentChange(i
)
129 qconnect(fW
, qt
.SIGNAL("currentChanged(QListViewItem*)"), self
.newCurLambda
)
131 # The diff viewing widget
132 self
.text
= qt
.QWidgetStack(splitter
)
134 ops
= qt
.QPopupMenu(self
)
135 ops
.setCheckable(True)
136 ops
.insertItem("Commit Selected Files", self
.commit
, Qt
.CTRL
+Qt
.Key_T
)
137 ops
.insertItem("Refresh", self
.refreshFiles
, Qt
.CTRL
+Qt
.Key_R
)
138 ops
.insertItem("(Un)select All", self
.toggleSelectAll
, Qt
.CTRL
+Qt
.Key_S
)
139 self
.showUnknownItem
= ops
.insertItem("Show Unkown Files",
140 self
.toggleShowUnknown
,
142 ops
.insertItem("Preferences...", self
.showPrefs
, Qt
.CTRL
+Qt
.Key_P
)
143 ops
.setItemChecked(self
.showUnknownItem
, settings().showUnknown
)
144 self
.operations
= ops
147 m
.insertItem("&Operations", ops
)
149 h
= qt
.QPopupMenu(self
)
150 h
.insertItem("&About", self
.about
)
151 m
.insertItem("&Help", h
)
153 qconnect(fW
, qt
.SIGNAL("contextMenuRequested(QListViewItem*, const QPoint&, int)"),
154 self
.contextMenuRequestedSlot
)
155 self
.fileOps
= qt
.QPopupMenu(self
)
156 self
.fileOps
.insertItem("Toggle selection", self
.toggleFile
)
157 self
.fileOps
.insertItem("Edit", self
.editFile
, Qt
.CTRL
+Qt
.Key_E
)
158 self
.fileOps
.insertItem("Discard changes", self
.discardFile
)
159 self
.fileOps
.insertItem("Ignore file", self
.ignoreFile
)
161 # The following attribute is set by contextMenuRequestedSlot
162 # and currentChange and used by the fileOps
163 self
.currentContextItem
= None
165 self
.patchColors
= {'std': 'black', 'new': '#009600', 'remove': '#C80000', 'head': '#C800C8'}
167 self
.files
= scm
.fileSetFactory(lambda f
: self
.addFile(f
),
168 lambda f
: self
.removeFile(f
))
170 f
.text
= "Commit message"
171 f
.textW
= self
.newTextEdit()
172 f
.textW
.setTextFormat(Qt
.PlainText
)
173 f
.textW
.setReadOnly(False)
174 f
.textW
.setText(settings().signoff
)
175 qconnect(f
.textW
, qt
.SIGNAL('cursorPositionChanged(int, int)'),
176 self
.updateCommitCursor
)
178 self
.createCmitItem()
179 self
.editorProcesses
= sets
.Set()
182 self
.options
= options
184 def updateStatusBar(self
):
185 if not self
.cmitFile
.textW
.isVisible():
186 self
.setStatusBar('')
188 def setStatusBar(self
, string
):
189 branch
= scm
.getCurrentBranch()
191 prefix
= '[' + branch
+ '] '
194 self
.statusBar().message(prefix
+ string
)
196 def updateCommitCursor(self
, *dummy
):
197 [line
, col
] = self
.cmitFile
.textW
.getCursorPosition()
198 self
.setStatusBar('Column: ' + str(col
))
200 def loadSettings(self
):
201 self
.splitter
.setSizes(settings().splitter
)
203 def closeEvent(self
, e
):
205 settings().width
= s
.width()
206 settings().height
= s
.height()
207 settings().splitter
= self
.splitter
.sizes()
210 def createCmitItem(self
):
211 self
.cmitItem
= MyListItem(self
.filesW
, self
.cmitFile
, True)
212 self
.cmitItem
.setSelectable(False)
213 self
.filesW
.insertItem(self
.cmitItem
)
214 self
.cmitFile
.listViewItem
= self
.cmitItem
216 def about(self
, ignore
):
217 str = '<qt><center><h1>%(appName)s %(version)s</h1>' \
218 '<p>Copyright © 2005 Fredrik Kuivinen <frekui@gmail.com></p>' \
219 '<p>Copyright © 2005 Mark Williamson <maw48@cl.cam.ac.uk></p></center>' \
220 '<p>This program is free software; you can redistribute it ' \
221 'and/or modify it under the terms of the GNU General Public ' \
222 'License version 2 as published by the Free Software Foundation.' \
223 '</p></qt>' % {'appName': applicationName
, 'version': version
}
225 qt
.QMessageBox
.about(self
, "About " + applicationName
, str)
227 def contextMenuRequestedSlot(self
, item
, pos
, col
):
228 if item
and not item
.commitMsg
:
229 self
.currentContextItem
= item
230 self
.fileOps
.exec_loop(qt
.QCursor
.pos())
232 self
.currentContextItem
= None
234 def toggleFile(self
, ignored
):
235 it
= self
.currentContextItem
240 it
.setSelected(False)
244 def editFile(self
, ignored
):
245 it
= self
.currentContextItem
251 qt
.QMessageBox
.warning(self
, 'No editor found',
252 '''No editor found. Gct looks for an editor to execute in the environment
253 variable GCT_EDITOR, if that variable is not set it will use the variable
257 # This piece of code is not entirely satisfactory. If the user
258 # has EDITOR set to 'vi', or some other non-X application, the
259 # editor will be started in the terminal which (h)gct was
260 # started in. A better approach would be to close stdin and
261 # stdout after the fork but before the exec, but this doesn't
262 # seem to be possible with QProcess.
264 p
.addArgument(it
.file.dstName
)
265 p
.setCommunication(0)
266 qconnect(p
, qt
.SIGNAL('processExited()'), self
.editorExited
)
267 if not p
.launch(qt
.QByteArray()):
268 qt
.QMessageBox
.warning(self
, 'Failed to launch editor',
269 shortName
+ ' failed to launch the ' + \
270 'editor. The command used was: ' + \
271 ed
+ ' ' + it
.file.dstName
)
273 self
.editorProcesses
.add(p
)
275 def editorExited(self
):
277 status
= p
.exitStatus()
278 file = unicode(p
.arguments()[1])
279 editor
= unicode(p
.arguments()[0]) + ' ' + file
280 if not p
.normalExit():
281 qt
.QMessageBox
.warning(self
, 'Editor failure',
282 'The editor, ' + editor
+ ', exited abnormally.')
284 qt
.QMessageBox
.warning(self
, 'Editor failure',
285 'The editor, ' + editor
+ ', exited with exit code ' + str(status
))
287 self
.editorProcesses
.remove(p
)
288 scm
.doUpdateCache(file)
291 def discardFile(self
, ignored
):
292 it
= self
.currentContextItem
296 scm
.discardFile(it
.file)
299 def ignoreFile(self
, ignored
):
300 it
= self
.currentContextItem
304 scm
.ignoreFile(it
.file)
307 def currentChange(self
, item
):
310 f
.textW
= self
.newTextEdit()
311 f
.textW
.setReadOnly(True)
312 f
.textW
.setTextFormat(Qt
.RichText
)
313 f
.textW
.setText(formatPatchRichText(f
.getPatch(), self
.patchColors
))
315 self
.text
.raiseWidget(f
.textW
)
316 self
.currentContextItem
= item
318 self
.updateCommitCursor()
320 def commit(self
, id):
325 for item
in self
.filesW
:
326 debug("file: " + item
.file.text
)
327 if item
.isSelected():
328 selFileNames
.append(item
.file.text
)
329 commitFiles
.append(item
.file)
331 keepFiles
.append(item
.file)
333 commitMsg
= unicode(self
.cmitItem
.file.textW
.text())
336 qt
.QMessageBox
.information(self
, "Commit - " + applicationName
,
337 "No files selected for commit.", "&Ok")
340 commitMsg
= fixCommitMsgWhiteSpace(commitMsg
)
341 if scm
.commitIsMerge():
342 mergeMsg
= scm
.mergeMessage()
346 commitDialog
= CommitDialog(mergeMsg
, commitMsg
, selFileNames
)
347 if commitDialog
.exec_loop():
349 scm
.doCommit(keepFiles
, commitFiles
, commitMsg
)
350 except ProgramError
, e
:
351 qt
.QMessageBox
.warning(self
, "Commit Failed - " + applicationName
,
352 "Commit failed: " + str(e
),
355 if not self
.options
.oneshot
:
356 self
.cmitItem
.file.textW
.setText(settings().signoff
)
358 self
.statusBar().message('Commit done')
360 if self
.options
.oneshot
:
363 def getFileState(self
):
365 cur
= self
.filesW
.currentItem()
366 if cur
and cur
!= self
.cmitItem
:
367 ret
.current
= self
.filesW
.currentItem().file.text
370 ret
.selected
= sets
.Set()
372 for x
in self
.filesW
:
374 ret
.selected
.add(x
.file.text
)
378 def restoreFileState(self
, state
):
380 f
.listViewItem
.setSelected(f
.text
in state
.selected
)
382 for x
in self
.filesW
:
383 if x
.file.text
== state
.current
:
384 self
.filesW
.setCurrentItem(x
)
386 def newTextEdit(self
):
388 self
.text
.addWidget(ret
)
391 def addFile(self
, file):
393 f
.listViewItem
= MyListItem(self
.filesW
, f
)
395 # The patch for this file is generated lazily in currentChange
397 # Only display files that match the filter.
398 f
.listViewItem
.setVisible(self
.filterMatch(f
))
400 self
.filesW
.insertItem(f
.listViewItem
)
403 def removeFile(self
, file):
405 self
.text
.removeWidget(f
.textW
)
406 self
.filesW
.takeItem(f
.listViewItem
)
407 f
.listViewItem
= None
409 def refreshFiles(self
):
410 state
= self
.getFileState()
412 self
.setUpdatesEnabled(False)
413 scm
.updateFiles(self
.files
)
414 self
.filesW
.setCurrentItem(self
.cmitItem
)
416 # For some reason the currentChanged signal isn't emitted
417 # here. We call currentChange ourselves instead.
418 self
.currentChange(self
.cmitItem
)
419 self
.restoreFileState(state
)
420 self
.setUpdatesEnabled(True)
423 if settings().quitOnNoChanges
and len(self
.files
) == 0:
425 return len(self
.files
) > 0
427 def filterMatch(self
, file):
428 return file.dstName
.find(unicode(self
.filter.text())) != -1
430 def updateFilter(self
, ignored
=None):
431 for w
in self
.filesW
:
432 w
.setVisible(self
.filterMatch(w
.file))
434 def clearFilter(self
):
435 self
.filter.setText("")
437 def toggleSelectAll(self
):
439 for x
in self
.filesW
:
441 if not x
.isSelected():
446 for x
in self
.filesW
:
450 def toggleShowUnknown(self
):
451 if settings().showUnknown
:
452 settings().showUnknown
= False
454 settings().showUnknown
= True
456 self
.operations
.setItemChecked(self
.showUnknownItem
, settings().showUnknown
)
460 if settings().showSettings():
463 commitMsgRE
= re
.compile('[ \t\r\f\v]*\n\\s*\n')
464 def fixCommitMsgWhiteSpace(msg
):
467 msg
= re
.sub(commitMsgRE
, '\n\n', msg
)
471 def formatPatchRichText(patch
, colors
):
472 ret
= ['<qt><pre><font color="', colors
['std'], '">']
474 for l
in patch
.split('\n'):
481 if c
== '+': style
= 'new'
482 elif c
== '-': style
= 'remove'
483 elif c
== '@': style
= 'head'
485 ret
.extend(['</font><font color="', colors
[style
], '">'])
487 line
= unicode(qt
.QStyleSheet
.escape(l
))
488 ret
.extend([line
, '\n'])
489 ret
.append('</pre></qt>')
493 if os
.environ
.has_key('GCT_EDITOR'):
494 return os
.environ
['GCT_EDITOR']
495 elif os
.environ
.has_key('EDITOR'):
496 return os
.environ
['EDITOR']
500 class EventFilter(qt
.QObject
):
501 def __init__(self
, parent
, mainWidget
):
502 qt
.QObject
.__init
__(self
, parent
)
505 def eventFilter(self
, watched
, e
):
506 if (e
.type() == qt
.QEvent
.KeyRelease
or \
507 e
.type() == qt
.QEvent
.MouseButtonRelease
):
508 self
.mw
.updateStatusBar()
515 app
= qt
.QApplication(sys
.argv
)
517 optParser
= OptionParser(usage
="%prog [--gui] [--one-shot]", version
=applicationName
+ ' ' + version
)
518 optParser
.add_option('-g', '--gui', action
='store_true', dest
='gui',
519 help='Unconditionally start the GUI')
520 optParser
.add_option('-o', '--one-shot', action
='store_true', dest
='oneshot',
521 help="Do (at most) one commit, then exit.")
522 (options
, args
) = optParser
.parse_args(app
.argv()[1:])
524 mw
= MainWidget(options
)
525 ef
= EventFilter(None, mw
)
526 app
.installEventFilter(ef
)
528 if not mw
.refreshFiles() and settings().quitOnNoChanges
and not options
.gui
:
529 print 'No outstanding changes'
532 mw
.resize(settings().width
, settings().height
)
535 app
.setMainWidget(mw
)
537 # Handle CTRL-C appropriately
538 signal
.signal(signal
.SIGINT
, lambda s
, f
: app
.quit())
540 ret
= app
.exec_loop()
541 settings().writeSettings()